diff --git a/src/app/catalog/[shop]/[category]/[indexCategoryParam]/page.tsx b/src/app/catalog/[shop]/[category]/[indexCategoryParam]/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5b9cc50176953fd0f4db345704bdfc042b84122f --- /dev/null +++ b/src/app/catalog/[shop]/[category]/[indexCategoryParam]/page.tsx @@ -0,0 +1,38 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import { FC, Suspense } from 'react'; + +import { CategoryPage } from '@/view'; +import { getQueryClient } from '@/utils'; +import { getCatalog } from '@/api'; + +import Loading from '../../../../loading'; + +interface ICategory { + params: { + shop: string; + category: string; + indexCategoryParam: string; + }; +} +const Category: FC = ({ params }) => { + const { shop, category, indexCategoryParam } = params; + + const queryClient = getQueryClient(); + queryClient.prefetchQuery({ + queryKey: ['catalog', shop], + queryFn: async () => getCatalog(shop), + }); + const dehydratedState = dehydrate(queryClient); + return ( + }> + + + + + ); +}; +export default Category; diff --git a/src/app/catalog/[shop]/[category]/page.tsx b/src/app/catalog/[shop]/[category]/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5de00409383cee59f71702e4a1d4baaad485a40c --- /dev/null +++ b/src/app/catalog/[shop]/[category]/page.tsx @@ -0,0 +1,32 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import { FC, Suspense } from 'react'; + +import { CatalogPage } from '@/view'; +import { getQueryClient } from '@/utils'; +import { getCatalog } from '@/api'; + +import Loading from '../../../loading'; + +interface ICatalog { + params: { + shop: string; + category: string; + }; +} +const Catalog: FC = async ({ params }) => { + const { shop, category } = params; + const queryClient = getQueryClient(); + await queryClient.prefetchQuery({ + queryKey: ['catalog', shop], + queryFn: async () => getCatalog(shop), + }); + const dehydratedState = dehydrate(queryClient); + return ( + + }> + + + + ); +}; +export default Catalog; diff --git a/src/app/catalog/[shop]/product/[item]/page.tsx b/src/app/catalog/[shop]/product/[item]/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6d16439d696b1ec4f40d8d274782d0509c57b12d --- /dev/null +++ b/src/app/catalog/[shop]/product/[item]/page.tsx @@ -0,0 +1,49 @@ +import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; +import { FC, Suspense } from 'react'; + +import { ProductPage } from '@/view'; +import { getQueryClient } from '@/utils'; +import { getProduct, postRecommendations } from '@/api'; + +import Loading from '../../../../loading'; + +interface IProduct { + params: { + shop: string; + item: string; + }; +} + +const Product: FC = async ({ params }) => { + const { shop, item } = params; + const queryClient = getQueryClient(); + + try { + await Promise.all([ + queryClient.prefetchQuery({ + queryKey: ['catalog', shop, item], + queryFn: () => getProduct(shop, item), + }), + queryClient.prefetchQuery({ + queryKey: ['promo', shop, item], + queryFn: () => + postRecommendations({ + shop: shop || '', + currentItem: item || '', + }), + }), + ]); + } catch (error) { + console.error('Error prefetching data:', error); + } + + const dehydratedState = dehydrate(queryClient); + return ( + }> + + + + + ); +}; +export default Product; diff --git a/src/app/favicon.ico b/src/app/favicon.ico deleted file mode 100644 index 718d6fea4835ec2d246af9800eddb7ffb276240c..0000000000000000000000000000000000000000 Binary files a/src/app/favicon.ico and /dev/null differ diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c96ac0bdca0bec6cc2795ced824c05cce8e38c7b..c8ed4361bea6828a9334d408e3c03f4d4af72dbc 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -11,10 +11,10 @@ import { Widget, } from '@/components'; -import StoreProvider from './store-provider'; -import './globals.css'; +import StoreProvider from '../lib/store-provider'; +import '../styles/globals.css'; import StyledComponentsRegistry from '../lib/registry'; -import ReactQueryProvider from './query-provider'; +import ReactQueryProvider from '../lib/query-provider'; import type { Metadata } from 'next'; diff --git a/src/app/lib/registry.tsx b/src/app/lib/registry.tsx deleted file mode 100644 index 04b0fea40f229f3d5cc8c9fd4f9dd5b1f25ea59c..0000000000000000000000000000000000000000 --- a/src/app/lib/registry.tsx +++ /dev/null @@ -1,30 +0,0 @@ -'use client'; - -import { ReactNode, useState } from 'react'; -import { useServerInsertedHTML } from 'next/navigation'; -import { ServerStyleSheet, StyleSheetManager } from 'styled-components'; - - -const StyledComponentsRegistry = ({ - children, -}: { - children: ReactNode; -}) => { - const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet()); - - useServerInsertedHTML(() => { - const styles = styledComponentsStyleSheet.getStyleElement(); - styledComponentsStyleSheet.instance.clearTag(); - return <>{ styles }; - }); - - if (typeof window !== 'undefined') return <>{ children }; - - return ( - - { children } - - ); -}; - -export default StyledComponentsRegistry; diff --git a/src/app/loading.tsx b/src/app/loading.tsx index e8b963edbd2ae2598303fde53456713a8db90f8a..4c299bcff57b50523e33a8133d38cf2c77efd855 100644 --- a/src/app/loading.tsx +++ b/src/app/loading.tsx @@ -3,7 +3,7 @@ import { Preloader } from '@/vendor'; const Loading = () => { - return ; + return ; }; export default Loading; diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b6f551e805823d844b7ee6627faaa933a82fe5ed --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,14 @@ +import { Suspense } from 'react'; + +import { LoginPage } from '@/view'; + +import Loading from '../loading'; + +const Login = () => { + return ( + }> + + + ); +}; +export default Login; diff --git a/src/app/page.tsx b/src/app/page.tsx index f68e8714a81eb28ea610ae9068edcbaaca8d4109..3b39017ad2bdbbcaf04408661b5d79e287fdd3e3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,9 +1,12 @@ import { dehydrate, HydrationBoundary } from '@tanstack/react-query'; +import { Suspense } from 'react'; -import { ShopListPage } from '@/page'; +import { ShopListPage } from '@/view'; import { getShops } from '@/api'; import { getQueryClient } from '@/utils'; +import Loading from './loading'; + const Home = async () => { const queryClient = getQueryClient(); await queryClient.prefetchQuery({ @@ -14,9 +17,11 @@ const Home = async () => { const dehydratedState = dehydrate(queryClient); return ( - - - + }> + + + + ); }; diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..928e2d7b5761f54e13c261c6604ebc996b4af7ff --- /dev/null +++ b/src/app/profile/page.tsx @@ -0,0 +1,14 @@ +import { Suspense } from 'react'; + +import { ProfilePage } from '@/view'; + +import Loading from '../loading'; + +const Profile = () => { + return ( + }> + + + ); +}; +export default Profile; diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1fcb613a36706664d62247dc3bf61ac818375b4c --- /dev/null +++ b/src/app/signup/page.tsx @@ -0,0 +1,14 @@ +import { Suspense } from 'react'; + +import { RegisterPage } from '@/view'; + +import Loading from '../loading'; + +const Register = () => { + return ( + }> + + + ); +}; +export default Register; diff --git a/src/components/cart/cart.tsx b/src/components/cart/cart.tsx new file mode 100644 index 0000000000000000000000000000000000000000..aa4372a79132b53d1860023fec906805ce83a1bd --- /dev/null +++ b/src/components/cart/cart.tsx @@ -0,0 +1,92 @@ +'use client'; + +import React, { FC, useState } from 'react'; +import { AnimatePresence } from 'framer-motion'; + +import { Stylebook } from '@/interfaces'; +import { useAppSelector } from '@/hooks'; +import { LayoutModal, Portal } from '@/shared'; +import { basketSelector } from '@/store'; + +import * as Styled from './styled'; +import { + CartHeader, + CartForm, + PaymentCheckModal, + PaymentErrorModal, + PaymentSuccessModal, +} from './ui'; + +interface ICartProps { + handleCloseCart: () => void; + shop: string; + stylebook: Stylebook | null; +} +export const Cart: FC = ({ handleCloseCart, shop, stylebook }) => { + const basket = useAppSelector(basketSelector); + const [isModalCheck, setIsModalCheck] = useState(false); + const [isModalError, setIsModalError] = useState(false); + const [isModalSuccess, setIsModalSuccess] = useState(false); + + const handleOpenModal = (status: string) => { + if (status === 'success') setIsModalSuccess(true); + if (status === 'check') setIsModalCheck(true); + if (status === 'error') setIsModalError(true); + }; + const handleCloseModal = () => { + setIsModalCheck(false); + setIsModalSuccess(false); + setIsModalError(false); + }; + + return ( + { + e.stopPropagation(); + }} + > + + + + + + + {isModalCheck && ( + + + + + + )} + + + {isModalError && ( + + + + + + )} + + + {isModalSuccess && ( + + + + + + )} + + + ); +}; diff --git a/src/components/cart/index.ts b/src/components/cart/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..570b0189df97bb208d2af19742eded68dffefec1 --- /dev/null +++ b/src/components/cart/index.ts @@ -0,0 +1,2 @@ +export * from './cart'; +export * from './ui'; diff --git a/src/components/cart/styled.tsx b/src/components/cart/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..40bcc11e12558344d7d473f018abdc5bee997444 --- /dev/null +++ b/src/components/cart/styled.tsx @@ -0,0 +1,43 @@ +import styled from 'styled-components'; + +import { devices } from '@/styles'; + +export const Cart = styled.div` + position: absolute; + inset: 0; + z-index: 2; + display: flex; + flex-direction: column; + justify-content: end; + + @media ${devices.tablet} { + inset: auto; + right: 0; + bottom: 0; + width: 50vw; + height: 90vh; + max-height: 800px; + } + + @media ${devices.mobile} { + right: 0; + bottom: 0; + left: 0; + width: auto; + } +`; + +export const TopPattern = styled.div` + width: 100%; + height: 16px; + background-color: var(--white); + mask: url('/patterns/modal-waves.png'); +`; + +export const Wrapper = styled.div` + display: flex; + flex-direction: column; + min-height: 90%; + padding-bottom: 24px; + background-color: var(--white); +`; diff --git a/src/components/cart/ui/cart-add-button/cart-add-button.tsx b/src/components/cart/ui/cart-add-button/cart-add-button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2cef247b0e94b6f19d8b9640f6870682d01fa318 --- /dev/null +++ b/src/components/cart/ui/cart-add-button/cart-add-button.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { FC } from 'react'; + +import { Icon } from '@/shared'; + +import * as Styled from './styled'; + +interface AddButtonProps { + onIncrease: () => void; + onDecrease: () => void; + value: number; + colorItem?: string; +} + +export const AddButton: FC = ({ + value, + onIncrease, + onDecrease, + colorItem, +}) => ( + + + + + {value} + + + + +); diff --git a/src/components/cart/ui/cart-add-button/index.ts b/src/components/cart/ui/cart-add-button/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c60ddbc8b2112f8eb8b48b39f07d4c14c77176ec --- /dev/null +++ b/src/components/cart/ui/cart-add-button/index.ts @@ -0,0 +1 @@ +export * from './cart-add-button'; diff --git a/src/components/cart/ui/cart-add-button/styled.tsx b/src/components/cart/ui/cart-add-button/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f4f5309feb21415d22cd5493699b6ed6345fe154 --- /dev/null +++ b/src/components/cart/ui/cart-add-button/styled.tsx @@ -0,0 +1,35 @@ +import styled from 'styled-components'; + +interface AddButtonProps { + $colorItem?: string; +} + +export const Counter = styled.div` + box-sizing: border-box; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + height: 100%; + color: var(--green-minus); + user-select: none; + background-color: var(--white); + border: 2px solid var(--black-plus); + border-radius: 5px; +`; + +export const CounterButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + padding: 0 4px; + color: ${({ $colorItem }) => $colorItem || 'black'}; + cursor: pointer; +`; + +export const Count = styled.p` + font-family: Gilroy, sans-serif; + font-size: 12px; + font-weight: 800; + color: ${({ $colorItem }) => $colorItem || 'black'}; +`; diff --git a/src/components/cart/ui/cart-form/cart-form.tsx b/src/components/cart/ui/cart-form/cart-form.tsx new file mode 100644 index 0000000000000000000000000000000000000000..76721c2c4205ce6f871175b2bbf01699749185c3 --- /dev/null +++ b/src/components/cart/ui/cart-form/cart-form.tsx @@ -0,0 +1,237 @@ +'use client'; + +import { SubmitHandler, useFieldArray, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { FC, useEffect, useMemo } from 'react'; +import { getHours } from 'date-fns'; +import { isAxiosError } from 'axios'; +import { useRouter } from 'next/navigation'; + +import { usePaymentMutation, useShops } from '@/api-hooks'; +import { useAppDispatch, useAppSelector } from '@/hooks'; +import { addItem, basketSelector, clearCart, removeItem } from '@/store'; +import { Preloader } from '@/vendor'; +import { CustomSelect, Input } from '@/shared'; +import { IProductState, Stylebook } from '@/interfaces'; + +import * as Styled from './styled'; +import { CartSchema, TCartSchema } from './schema'; +import { ProductListGroup } from '../product-list-group'; +import { CartPayment } from '../cart-payment'; +import { RadioWithText } from '../radio-with-text'; +import { RadioCartButton } from '../radio-cart-button'; +import { PICKUP_METHOD_DATA, PICKUP_TIME_DATA, getShopsList } from './utils'; + +interface ICartFormProps { + handleCloseCart: () => void; + handleOpenModal: (status: string) => void; + handleCloseModal: () => void; + stylebook: Stylebook | null; + shop: string; +} + +interface BasketItem { + slug: string; + count: number; +} + +interface IPaymentProps { + shop: string; + time: number; + basket: BasketItem[]; +} + +interface IProduct { + productSlug: string; + addons: string[]; + modification?: string; + count: number; + cost: number; +} + +export const CartForm: FC = ({ + handleOpenModal, + handleCloseModal, + handleCloseCart, + stylebook, + shop, +}) => { + const { data, isError, error, isLoading } = useShops(); + + const currentShopData = data?.find((item) => item.slug === shop); + const addresses = useMemo(() => getShopsList(data || []), [data]); + + const basket = useAppSelector(basketSelector); + const dispatch = useAppDispatch(); + const router = useRouter(); + + const handleError = (reqError: Error) => { + if (isAxiosError(reqError) && reqError.code === '404') + router.replace('/404'); + if (isAxiosError(reqError) && reqError.code === '500') + router.replace('/server-error'); + handleCloseModal(); + handleOpenModal('error'); + }; + + const handleSuccess = () => { + handleCloseModal(); + handleOpenModal('success'); + }; + + const { + control, + handleSubmit, + watch, + resetField, + formState: { isValid }, + } = useForm({ + resolver: zodResolver(CartSchema), + defaultValues: { + address: currentShopData?.address.toLowerCase() || '', + comment: '', + pickupTime: PICKUP_TIME_DATA[0].value, + pickupMethod: 'С собой', + products: basket[shop]?.items, + agreeWithTerms: false, + }, + mode: 'onTouched', + }); + + const { fields } = useFieldArray({ + control, + name: 'products', + }); + + const activeValueTime = watch('pickupTime'); + const activeValueMethod = watch('pickupMethod'); + const activeValueTerms = watch('agreeWithTerms'); + + const { mutate, isPending } = usePaymentMutation(handleError, handleSuccess); + + const onSubmit: SubmitHandler = (dataForm) => { + handleOpenModal('check'); + const paymentData: IPaymentProps = { + shop: '', + time: 0, + basket: [], + }; + paymentData.shop = shop; + paymentData.time = getHours(new Date()); + paymentData.basket = []; + dataForm.products.forEach((item: IProduct) => { + paymentData.basket.push({ slug: item.productSlug, count: item.count }); + }); + + mutate(paymentData); + }; + + const handleDecreaseCount = (item: IProductState) => { + if (shop) dispatch(removeItem({ shop, item })); + }; + + const handleIncreaseCount = (item: IProductState) => { + if (shop) dispatch(addItem({ shop, item })); + }; + + const handleClearCart = () => { + dispatch(clearCart({ shop })); + handleCloseCart(); + }; + + useEffect(() => { + resetField('address', { + defaultValue: currentShopData?.address.toLowerCase(), + }); + }, [currentShopData, resetField]); + + useEffect(() => { + resetField('products', { defaultValue: basket[shop]?.items }); + if (basket[shop]?.items.length === 0) handleCloseCart(); + }, [resetField, shop, basket, handleCloseCart]); + + if (isLoading) return ; + + if (isError) { + return
Произошла ошибка: {error.message}
; + } + + return ( + + + + + + + + + Заберёте заказ через + + {PICKUP_TIME_DATA.map((item) => ( + + + + ))} + + + + {PICKUP_METHOD_DATA.map((item) => ( + + + + ))} + + + + + + + + + + + + ); +}; diff --git a/src/components/cart/ui/cart-form/index.ts b/src/components/cart/ui/cart-form/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..308ef8b5722580f61f6fdca266ef9549c6948b66 --- /dev/null +++ b/src/components/cart/ui/cart-form/index.ts @@ -0,0 +1,2 @@ +export * from './cart-form'; +export * from './schema'; diff --git a/src/components/cart/ui/cart-form/schema/index.ts b/src/components/cart/ui/cart-form/schema/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..907c0477e5df6eff1a2ed73196f01a19582326c6 --- /dev/null +++ b/src/components/cart/ui/cart-form/schema/index.ts @@ -0,0 +1 @@ +export * from './schema-cart-form'; diff --git a/src/components/cart/ui/cart-form/schema/schema-cart-form.ts b/src/components/cart/ui/cart-form/schema/schema-cart-form.ts new file mode 100644 index 0000000000000000000000000000000000000000..12017d2e18bafd24e0385882142df45fae7a226c --- /dev/null +++ b/src/components/cart/ui/cart-form/schema/schema-cart-form.ts @@ -0,0 +1,27 @@ +import { z, ZodType } from 'zod'; + +const ProductStateSchema = z.object({ + productSlug: z.string(), + addons: z.array(z.string()), + modification: z.string().optional(), + count: z.number(), + cost: z.number(), +}); + +export const CartSchema: ZodType = z + .object({ + address: z.string().optional(), + comment: z.string().max(30).optional(), + pickupTime: z.string(), + pickupMethod: z.string(), + products: z.array(ProductStateSchema).nonempty({ + message: "Can't be empty!", + }), + agreeWithTerms: z.boolean(), + }) + .refine((data) => data.agreeWithTerms === true, { + message: 'agreeWithTerms должен быть true', + path: ['agreeWithTerms'], + }); + +export type TCartSchema = z.infer; diff --git a/src/components/cart/ui/cart-form/styled.tsx b/src/components/cart/ui/cart-form/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..48ba26ab5a2ec649de8090782584886c475a0248 --- /dev/null +++ b/src/components/cart/ui/cart-form/styled.tsx @@ -0,0 +1,72 @@ +import ScrollContainer from 'react-indiana-drag-scroll'; +import styled from 'styled-components'; + +export const CartForm = styled.form` + position: relative; + display: flex; + flex-direction: column; + gap: 20px; + align-items: center; + height: 100%; +`; + +export const SelectGroup = styled.div` + box-sizing: border-box; + width: 100%; + padding-right: 40px; + padding-left: 40px; +`; + +export const RadioButtonsGroup = styled.div` + display: flex; + flex-direction: column; + width: 100%; + max-width: 100%; +`; + +export const RadioPickupTimeGroup = styled(ScrollContainer)` + display: flex; + flex-direction: row; + gap: 12px; + padding-right: 40px; + padding-left: 40px; + margin: 10px 0 20px; + user-select: none; +`; + +export const PickupLabel = styled.p` + display: flex; + flex-direction: column; + padding-left: 40px; + margin-bottom: 6px; + font-family: ${(props) => props.theme.fonts}; + font-size: 12px; + font-weight: 800; + line-height: 16px; + color: var(--gray); +`; + +export const RadioItem = styled.div``; + +export const PickupMethodGroup = styled(ScrollContainer)` + display: flex; + flex-direction: row; + gap: 56px; + padding-right: 40px; + padding-left: 40px; + white-space: nowrap; + user-select: none; +`; + +export const CartProductList = styled.div` + display: flex; + flex-direction: column; + width: 100%; +`; + +export const CartPaymentGroup = styled.div` + position: absolute; + bottom: 0; + width: 100%; + height: 76px; +`; diff --git a/src/components/cart/ui/cart-form/utils/constants/constants.ts b/src/components/cart/ui/cart-form/utils/constants/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..cefe3bdbf2687589cc5bc44867be70bea553039d --- /dev/null +++ b/src/components/cart/ui/cart-form/utils/constants/constants.ts @@ -0,0 +1,11 @@ +export const PICKUP_TIME_DATA = [ + { id: 1, label: '5 минут', value: '5 минут' }, + { id: 2, label: '10 минут', value: '10 минут' }, + { id: 3, label: '15 минут', value: '15 минут' }, + { id: 4, label: '20 минут', value: '20 минут' }, +]; + +export const PICKUP_METHOD_DATA = [ + { id: 1, label: 'С собой', value: 'С собой' }, + { id: 2, label: 'В кофейне', value: 'В кофейне' }, +]; diff --git a/src/components/cart/ui/cart-form/utils/constants/index.ts b/src/components/cart/ui/cart-form/utils/constants/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c94f80f843a1ad2b1a576f42b4d4c20b796dce32 --- /dev/null +++ b/src/components/cart/ui/cart-form/utils/constants/index.ts @@ -0,0 +1 @@ +export * from './constants'; diff --git a/src/components/cart/ui/cart-form/utils/helpers/get-shop-list.ts b/src/components/cart/ui/cart-form/utils/helpers/get-shop-list.ts new file mode 100644 index 0000000000000000000000000000000000000000..419a1e4dd7ab893a0492241138e32f7c2a771cd3 --- /dev/null +++ b/src/components/cart/ui/cart-form/utils/helpers/get-shop-list.ts @@ -0,0 +1,17 @@ +import { Shop } from '@/interfaces'; + +interface IOption { + value: string; + label: string; +} + +export const getShopsList = (shops: Shop[] | null): IOption[] => { + const addresses: IOption[] = []; + shops?.forEach((item) => { + addresses.push({ + value: item.address.toLowerCase(), + label: `ул. ${item.address}`, + }); + }); + return addresses; +}; diff --git a/src/components/cart/ui/cart-form/utils/helpers/index.ts b/src/components/cart/ui/cart-form/utils/helpers/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..dcb2fd9e416bb5b07ba9d3ee9208296ee15992a7 --- /dev/null +++ b/src/components/cart/ui/cart-form/utils/helpers/index.ts @@ -0,0 +1 @@ +export * from './get-shop-list'; diff --git a/src/components/cart/ui/cart-form/utils/index.ts b/src/components/cart/ui/cart-form/utils/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..5a9087d1d2b5fe77da37ce8f8e5747d668ccd432 --- /dev/null +++ b/src/components/cart/ui/cart-form/utils/index.ts @@ -0,0 +1,2 @@ +export * from './constants'; +export * from './helpers'; diff --git a/src/components/cart/ui/cart-header/cart-header.tsx b/src/components/cart/ui/cart-header/cart-header.tsx new file mode 100644 index 0000000000000000000000000000000000000000..23acbcf91f99d446d7892de234a6e020e1660a73 --- /dev/null +++ b/src/components/cart/ui/cart-header/cart-header.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { FC } from 'react'; + +import { ActionButton } from '@/shared'; +import { formatPrice } from '@/utils'; + +import * as Styled from './styled'; + +interface ICartHeaderProps { + price: number; + handleCloseCart: () => void; + colorItem?: string; +} + +export const CartHeader: FC = ({ + price, + handleCloseCart, + colorItem, +}) => ( + + + Итого:{' '} + {formatPrice(price)} + + + +); diff --git a/src/components/cart/ui/cart-header/index.ts b/src/components/cart/ui/cart-header/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..1fc949c36bdb41a7bc18eaede517e034a24865fe --- /dev/null +++ b/src/components/cart/ui/cart-header/index.ts @@ -0,0 +1 @@ +export * from './cart-header'; diff --git a/src/components/cart/ui/cart-header/styled.tsx b/src/components/cart/ui/cart-header/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..12ae8a0bfbae0fc3b0eb1b988e59e2850e58908b --- /dev/null +++ b/src/components/cart/ui/cart-header/styled.tsx @@ -0,0 +1,25 @@ +import styled from 'styled-components'; + +interface ICartHeaderProps { + $colorItem?: string; +} + +export const CartHeader = styled.div` + box-sizing: border-box; + display: flex; + justify-content: space-between; + width: 100%; + padding: 6px 20px 20px; +`; + +export const PriceTitle = styled.h2` + font-family: ${(props) => props.theme.fonts}; + font-size: 18px; + font-weight: 800; + line-height: 26px; + color: var(--black-plus); +`; + +export const Price = styled.span` + color: ${({ $colorItem }) => $colorItem || 'black'}; +`; diff --git a/src/components/cart/ui/cart-item-control/cart-item-control.tsx b/src/components/cart/ui/cart-item-control/cart-item-control.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0a14294cda28d476f86455b2045f4a5b04adaa07 --- /dev/null +++ b/src/components/cart/ui/cart-item-control/cart-item-control.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { formatPrice } from '@/utils'; + +import { AddButton } from '../cart-add-button'; +import * as Styled from './styled'; + +export interface ICartItemControlProps { + onIncrease: () => void; + onDecrease: () => void; + count: number; + price: number; + colorItem: string; +} + +export const CartItemControl = ({ + onIncrease, + onDecrease, + count, + price, + colorItem, +}: ICartItemControlProps) => ( + + + + + + + {formatPrice(count === 0 ? price : price * count)} + + +); diff --git a/src/components/cart/ui/cart-item-control/index.ts b/src/components/cart/ui/cart-item-control/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..2d7e5b012c15ef4c98fb487ee6f0246d00bbe953 --- /dev/null +++ b/src/components/cart/ui/cart-item-control/index.ts @@ -0,0 +1 @@ +export * from './cart-item-control'; diff --git a/src/components/cart/ui/cart-item-control/styled.tsx b/src/components/cart/ui/cart-item-control/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5eeeac6432ab76645c059444b6a0d369030f7127 --- /dev/null +++ b/src/components/cart/ui/cart-item-control/styled.tsx @@ -0,0 +1,38 @@ +import styled from 'styled-components'; + +export const CartItemControl = styled.div` + display: grid; + grid-template-rows: clamp(36px, calc(0.8rem + 0.1vw), 44px); + grid-template-columns: 1fr 1fr 1fr; +`; + +export const AddButtonWrapper = styled.div` + z-index: 1; + grid-row: 1/2; + grid-column: 1/3; +`; + +export const PriceBackground = styled.div` + box-sizing: border-box; + display: flex; + grid-row: 1/2; + grid-column: 2/4; + align-items: center; + justify-content: center; + background-color: var(--black); + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; +`; + +export const Price = styled.div` + display: flex; + grid-row: 1/2; + grid-column: 3/4; + align-items: center; + justify-content: center; + font-family: ${(props) => props.theme.fonts}; + font-size: 10px; + font-weight: 800; + line-height: 14px; + color: var(--white); +`; diff --git a/src/components/cart/ui/cart-payment/cart-payment.tsx b/src/components/cart/ui/cart-payment/cart-payment.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bba9f410ff49f1fdf6f562708e5ea1515e5245ca --- /dev/null +++ b/src/components/cart/ui/cart-payment/cart-payment.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { FC } from 'react'; +import { Control } from 'react-hook-form'; + +import { Stylebook } from '@/interfaces'; + +import * as Styled from './styled'; +import { CartCheckbox } from '../checkbox-cart-button'; + +interface CartPaymentProps { + stylebook: Stylebook | null; + activeValueTerms: boolean; + isValid: boolean; + control: Control; + isPending: boolean; +} + +export const CartPayment: FC = ({ + stylebook, + activeValueTerms, + isValid, + control, + isPending, +}) => ( + + + + + + + + Оплатить + + +); diff --git a/src/components/cart/ui/cart-payment/index.ts b/src/components/cart/ui/cart-payment/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c65239b74fb15be17659026c4a13fab3375d0b7e --- /dev/null +++ b/src/components/cart/ui/cart-payment/index.ts @@ -0,0 +1 @@ +export * from './cart-payment'; diff --git a/src/components/cart/ui/cart-payment/styled.tsx b/src/components/cart/ui/cart-payment/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..92ce62a98c2b1b2e938f93b6a6af4f964c83d076 --- /dev/null +++ b/src/components/cart/ui/cart-payment/styled.tsx @@ -0,0 +1,95 @@ +import styled from 'styled-components'; + +import { Stylebook } from '@/interfaces'; + +interface ICartPaymentWrapperProps { + $stylebook?: Stylebook | null; +} + +const getPatternUrl = ($stylebook: Stylebook | null) => { + return `/patterns/${$stylebook?.pattern || 'diamonds'}.png`; +}; + +export const CartPayment = styled.div` + position: relative; + z-index: 1; + display: grid; + grid-template-rows: 1fr; + grid-template-columns: 2fr 24px 1fr; + height: 100%; + margin: 0 20px; + filter: drop-shadow(0 4px 4px rgb(0 0 0 / 25%)); +`; + +export const MaskElementBorder = styled.div` + position: relative; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--black-plus); + mask: url('/payment-border.svg'); +`; + +export const MaskElement = styled.div` + position: relative; + width: 100%; + height: 100%; + background-color: ${({ $stylebook }) => + $stylebook?.mainColor || 'var(--black)'}; + mask: url('/payment-mask.svg'); + + &::after { + position: absolute; + inset: 0; + content: ''; + background: url(${({ $stylebook }) => getPatternUrl($stylebook || null)}); + opacity: ${({ $stylebook }) => $stylebook?.opacity || '.7'}; + } +`; + +export const DotsLine = styled.div` + position: absolute; + width: 2px; + height: 30px; + background: url('/dots.svg'); + background-size: cover; +`; + +export const ButtonText = styled.span``; + +export const SubmitButton = styled.button` + position: relative; + z-index: 1; + font-family: ${(props) => props.theme.fonts}; + font-size: 14px; + font-weight: 800; + line-height: 22px; + color: var(--white); + cursor: pointer; + background-color: ${({ $stylebook }) => + $stylebook?.mainColor || 'var(--black)'}; + border-top: 2px solid var(--black-plus); + border-right: 2px solid var(--black-plus); + border-bottom: 2px solid var(--black-plus); + + &::after { + position: absolute; + inset: 0; + z-index: -1; + content: ''; + background: url(${({ $stylebook }) => getPatternUrl($stylebook || null)}); + opacity: ${({ $stylebook }) => $stylebook?.opacity || '.7'}; + } + + &:disabled { + cursor: auto; + + ${ButtonText} { + opacity: 0.5; + } + } + + &:not(:disabled):active { + opacity: 0.8; + } +`; diff --git a/src/components/cart/ui/cart-product-card/cart-product-card.tsx b/src/components/cart/ui/cart-product-card/cart-product-card.tsx new file mode 100644 index 0000000000000000000000000000000000000000..368b87e9be7bd1ded1e1e3c98bb24dddeb2bbc0b --- /dev/null +++ b/src/components/cart/ui/cart-product-card/cart-product-card.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { FC } from 'react'; + +import { IProductState } from '@/interfaces'; +import { Icon } from '@/shared'; + +import { CartItemControl } from '../cart-item-control'; +import * as Styled from './styled'; + +interface ICartProductCardProps { + product: IProductState; + onDecrease: (item: IProductState) => void; + onIncrease: (item: IProductState) => void; + colorItem: string; + onBlur?: () => void; +} + +export const CartProductCard: FC = ({ + product, + onDecrease, + onIncrease, + colorItem, + onBlur, +}) => { + const handleIncrease = () => { + onIncrease(product); + }; + const handleDecrease = () => { + onDecrease(product); + }; + + return ( + + + + {product.name} + + + {product.addons + ? product.addons.map((addon) => ( + + {addon} + )) + : null} + + + {product.modification && product.modification !== 'Без опций' ? ( + + {product.modification} + ) : null} + + + + + + ); +}; diff --git a/src/components/cart/ui/cart-product-card/index.ts b/src/components/cart/ui/cart-product-card/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..de8d5562f425bf5c1323fa30220375062b4c8fb3 --- /dev/null +++ b/src/components/cart/ui/cart-product-card/index.ts @@ -0,0 +1 @@ +export * from './cart-product-card'; diff --git a/src/components/cart/ui/cart-product-card/styled.tsx b/src/components/cart/ui/cart-product-card/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7ff30dcb605fdeebe193bd4fb650bda1809a7c7d --- /dev/null +++ b/src/components/cart/ui/cart-product-card/styled.tsx @@ -0,0 +1,72 @@ +import styled from 'styled-components'; + +export const CartProductCard = styled.div` + box-sizing: border-box; + display: grid; + grid-template-rows: 40px minmax(14px, auto) 52px; + grid-template-columns: 78px 4px 78px; + row-gap: 4px; + background-color: var(--black-plus); + border: 2px solid var(--black-plus); + border-radius: 10px; +`; + +export const TopContent = styled.div` + display: flex; + grid-column: 1/-1; + gap: 8px; + align-items: center; + justify-content: center; + background-color: var(--white); + border-radius: 10px; +`; + +export const Addons = styled.div` + display: flex; + flex-direction: column; + grid-row: 2/3; + grid-column: 1/2; + justify-content: flex-start; + padding-left: 10px; +`; + +export const Mods = styled.div` + display: flex; + grid-row: 2/3; + grid-column: 3/4; + justify-content: flex-end; + padding-right: 10px; +`; + +export const CountControl = styled.div` + grid-row: 3/4; + grid-column: 1/-1; + justify-self: center; + width: 140px; + height: 36px; + padding-top: 4px; +`; + +export const Option = styled.p` + max-width: 60px; + overflow: hidden; + font-family: ${(props) => props.theme.fonts}; + font-size: 10px; + font-weight: 800; + line-height: 14px; + color: var(--white); + text-overflow: ellipsis; + white-space: nowrap; +`; + +export const Title = styled.p` + max-width: 125px; + overflow: hidden; + font-family: ${(props) => props.theme.fonts}; + font-size: 12px; + font-weight: 800; + line-height: 16px; + color: var(--black); + text-overflow: ellipsis; + white-space: nowrap; +`; diff --git a/src/components/cart/ui/checkbox-cart-button/checkbox-cart-button.tsx b/src/components/cart/ui/checkbox-cart-button/checkbox-cart-button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6e9287b422be2d53327bea602f169464423bdb85 --- /dev/null +++ b/src/components/cart/ui/checkbox-cart-button/checkbox-cart-button.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { FC } from 'react'; +import { Control, Controller } from 'react-hook-form'; + +import { Stylebook } from '@/interfaces'; +import { Icon } from '@/shared'; + +import * as Styled from './styled'; + +interface ICartCheckbox { + stylebook: Stylebook | null; + activeValueTerms: boolean; + control: Control; +} + +export const CartCheckbox: FC = ({ + stylebook, + activeValueTerms, + control, +}) => ( + + + {activeValueTerms ? ( + + ) : ( + + )} + + ( + + )} + /> + Согласен с правилами оплаты + +); diff --git a/src/components/cart/ui/checkbox-cart-button/index.ts b/src/components/cart/ui/checkbox-cart-button/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c646cfca7a01a53335a74cbf88855a934f4b3509 --- /dev/null +++ b/src/components/cart/ui/checkbox-cart-button/index.ts @@ -0,0 +1 @@ +export * from './checkbox-cart-button'; diff --git a/src/components/cart/ui/checkbox-cart-button/styled.tsx b/src/components/cart/ui/checkbox-cart-button/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..53abf975268dfb80b6681c7356c693d12bcf2257 --- /dev/null +++ b/src/components/cart/ui/checkbox-cart-button/styled.tsx @@ -0,0 +1,59 @@ +import styled from 'styled-components'; + +import { Stylebook } from '@/interfaces'; + +interface ICartPaymentWrapperProps { + $stylebook?: Stylebook | null; +} +interface IStyledCartPaymentProps { + $colorItem?: string; +} + +const getPatternUrl = ($stylebook: Stylebook | null) => { + return `/patterns/${$stylebook?.pattern || 'diamonds'}.png`; +}; + +export const CheckboxLabel = styled.label` + position: relative; + z-index: 1; + display: flex; + gap: 12px; + align-items: center; + justify-content: center; + padding-left: 12px; + font-family: ${(props) => props.theme.fonts}; + font-size: 12px; + font-weight: 800; + line-height: 16px; + color: var(--white); + cursor: pointer; + user-select: none; + background-color: ${({ $stylebook }) => + $stylebook?.mainColor || 'var(--black)'}; + border-top: 2px solid var(--black-plus); + border-bottom: 2px solid var(--black-plus); + border-left: 2px solid var(--black-plus); + + &::after { + position: absolute; + inset: 0; + z-index: -1; + content: ''; + background: url(${({ $stylebook }) => getPatternUrl($stylebook || null)}); + opacity: ${({ $stylebook }) => $stylebook?.opacity || '.7'}; + } +`; + +export const IconWrapper = styled.div` + display: flex; + justify-content: start; + color: ${({ $colorItem }) => $colorItem || 'black'}; +`; + +export const Checkbox = styled.input` + display: none; +`; + +export const SvgBase = styled.svg` + filter: drop-shadow(2px 2px 2px rgb(0 0 0 / 25%)); +`; diff --git a/src/components/cart/ui/index.ts b/src/components/cart/ui/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4830b0e572ae9ef385e0d1d417588e37f74ef063 --- /dev/null +++ b/src/components/cart/ui/index.ts @@ -0,0 +1,11 @@ +export * from './cart-add-button'; +export * from './cart-form'; +export * from './cart-header'; +export * from './cart-item-control'; +export * from './cart-payment'; +export * from './cart-product-card'; +export * from './checkbox-cart-button'; +export * from './product-list-group'; +export * from './radio-cart-button'; +export * from './radio-with-text'; +export * from './modals'; diff --git a/src/components/cart/ui/modals/index.ts b/src/components/cart/ui/modals/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..bd614aacbb0ca0f59f3c02d994dfb7ddbe4eb368 --- /dev/null +++ b/src/components/cart/ui/modals/index.ts @@ -0,0 +1,3 @@ +export * from './payment-check-modal'; +export * from './payment-error-modal'; +export * from './payment-success-modal'; diff --git a/src/components/cart/ui/modals/modal-content/index.ts b/src/components/cart/ui/modals/modal-content/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..2c211af88f75c306576180c974b303bcd55d0458 --- /dev/null +++ b/src/components/cart/ui/modals/modal-content/index.ts @@ -0,0 +1 @@ +export * from './modal-content'; diff --git a/src/components/cart/ui/modals/modal-content/modal-content.tsx b/src/components/cart/ui/modals/modal-content/modal-content.tsx new file mode 100644 index 0000000000000000000000000000000000000000..124142d323580b46f85bc733562115f19eef59be --- /dev/null +++ b/src/components/cart/ui/modals/modal-content/modal-content.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { FC } from 'react'; + +import * as Styled from './styled'; + +interface IModalContent { + handleClose: () => void; + btnColor: string; + text: string; + subtitle: string; +} + +export const ModalContent: FC = ({ + handleClose, + btnColor, + text, + subtitle, +}) => ( + <> + {text} + {subtitle} + + Ok + + +); diff --git a/src/components/cart/ui/modals/modal-content/styled.tsx b/src/components/cart/ui/modals/modal-content/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1bf99004b175f240efe31fcecfd48fc77b5fa8b5 --- /dev/null +++ b/src/components/cart/ui/modals/modal-content/styled.tsx @@ -0,0 +1,70 @@ +import styled from 'styled-components'; + +import { devices } from '@/styles'; + +export const MessageText = styled.p` + font-family: ${(props) => props.theme.fonts}; + font-size: 18px; + font-weight: 800; + line-height: 26px; + color: var(--black-plus); + + @media ${devices.tablet} { + font-size: 20px; + line-height: 28px; + } + + @media ${devices.mobile} { + font-size: 18px; + line-height: 26px; + } +`; + +export const MessageSubtitle = styled.p` + font-family: ${(props) => props.theme.fonts}; + font-size: 13px; + font-weight: 800; + line-height: 16px; + color: var(--gray); + + @media ${devices.tablet} { + font-size: 16px; + line-height: 22px; + } + + @media ${devices.mobile} { + font-size: 13px; + line-height: 16px; + } +`; + +export const PaymentCheckButton = styled.button<{ $btnColor: string }>` + position: absolute; + right: 22px; + bottom: -20px; + display: inline-block; + padding: 12px 30px; + font-size: 14px; + font-weight: 800; + line-height: 20px; + color: var(--white); + text-align: center; + letter-spacing: 0; + cursor: pointer; + user-select: none; + background-color: var(${({ $btnColor }) => $btnColor}); + border: 2px solid var(--black-plus); + border-radius: 5px; + box-shadow: 2px 2px 2px rgb(0 0 0 / 25%); + transition: 0.2s ease; + + &:hover { + box-shadow: 3px 3px 8px rgb(0 0 0 / 30%); + transform: translateY(-1px); + } + + &:active { + box-shadow: initial; + transform: translateY(0); + } +`; diff --git a/src/components/cart/ui/modals/payment-check-modal/index.ts b/src/components/cart/ui/modals/payment-check-modal/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4e2060266252cb876d8e3e0e3da14effb7f36e25 --- /dev/null +++ b/src/components/cart/ui/modals/payment-check-modal/index.ts @@ -0,0 +1 @@ +export * from './payment-check-modal'; diff --git a/src/components/cart/ui/modals/payment-check-modal/payment-check-modal.tsx b/src/components/cart/ui/modals/payment-check-modal/payment-check-modal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..954b9e20d027a5c6cbf2827515a91bbd220f03d5 --- /dev/null +++ b/src/components/cart/ui/modals/payment-check-modal/payment-check-modal.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { Modal } from '@/shared'; + +import { ModalContent } from '../modal-content'; + +export const PaymentCheckModal = ({ + handleClose, +}: { + handleClose: () => void; +}) => { + const content = ( + + ); + + return ( + + ); +}; diff --git a/src/components/cart/ui/modals/payment-error-modal/index.ts b/src/components/cart/ui/modals/payment-error-modal/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b61620a1114641845cafbc378d87a8e62acbf132 --- /dev/null +++ b/src/components/cart/ui/modals/payment-error-modal/index.ts @@ -0,0 +1 @@ +export * from './payment-error-modal'; diff --git a/src/components/cart/ui/modals/payment-error-modal/payment-error-modal.tsx b/src/components/cart/ui/modals/payment-error-modal/payment-error-modal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d0d218af9ecead24ffe3f65d3e84c91f078a424b --- /dev/null +++ b/src/components/cart/ui/modals/payment-error-modal/payment-error-modal.tsx @@ -0,0 +1,29 @@ +'use client'; + +import { Modal } from '@/shared'; + +import { ModalContent } from '../modal-content'; + +export const PaymentErrorModal = ({ + handleClose, +}: { + handleClose: () => void; +}) => { + const content = ( + + ); + + return ( + + ); +}; diff --git a/src/components/cart/ui/modals/payment-success-modal/index.ts b/src/components/cart/ui/modals/payment-success-modal/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..32f5a5b783ee0105b721b27fd33af9d8f575a5c1 --- /dev/null +++ b/src/components/cart/ui/modals/payment-success-modal/index.ts @@ -0,0 +1 @@ +export * from './payment-success-modal'; diff --git a/src/components/cart/ui/modals/payment-success-modal/payment-success-modal.tsx b/src/components/cart/ui/modals/payment-success-modal/payment-success-modal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..93a88f41ca8457fd492aca7ba7400ea041ff868c --- /dev/null +++ b/src/components/cart/ui/modals/payment-success-modal/payment-success-modal.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { FC } from 'react'; + +import { useAppDispatch, useAppSelector } from '@/hooks'; +import { clearCart, userSelector } from '@/store'; +import { Modal } from '@/shared'; + +import { ModalContent } from '../modal-content'; + +interface IPaymentSuccessModal { + handleClose: () => void; + shop: string; +} + +export const PaymentSuccessModal: FC = ({ + handleClose, + shop, +}) => { + const user = useAppSelector(userSelector); + const dispatch = useAppDispatch(); + + const handleCloseModal = () => { + handleClose(); + dispatch(clearCart({ shop })); + }; + + const content = ( + + ); + + return ( + + ); +}; diff --git a/src/components/cart/ui/product-list-group/index.ts b/src/components/cart/ui/product-list-group/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..a28229903a25ed44a1681d185f132726119ee23d --- /dev/null +++ b/src/components/cart/ui/product-list-group/index.ts @@ -0,0 +1 @@ +export * from './product-list-group'; diff --git a/src/components/cart/ui/product-list-group/product-list-group.tsx b/src/components/cart/ui/product-list-group/product-list-group.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9214e514ee588f572d80934d9d8fc9aafc573c34 --- /dev/null +++ b/src/components/cart/ui/product-list-group/product-list-group.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { FC } from 'react'; +import { Control, Controller } from 'react-hook-form'; + +import { IProductState } from '@/interfaces'; + +import * as Styled from './styled'; +import { CartProductCard } from '../cart-product-card'; + +interface ProductListGroupProps { + colorItem: string; + onDecrease: (item: IProductState) => void; + onIncrease: (item: IProductState) => void; + control: Control; + fields: Record<'id', string>[]; + handleClearCart: () => void; +} + +export const ProductListGroup: FC = ({ + colorItem, + onDecrease, + onIncrease, + control, + fields, + handleClearCart, +}) => ( + + + Выбрано + + Убрать всё + + + + {fields.map((field) => ( + + ( + + onDecrease(field as IProductState & { id: string }) + } + /> + )} + /> + + ))} + + +); diff --git a/src/components/cart/ui/product-list-group/styled.tsx b/src/components/cart/ui/product-list-group/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..458420b079267bce8a5c02b87f9d0dfd5bdc460f --- /dev/null +++ b/src/components/cart/ui/product-list-group/styled.tsx @@ -0,0 +1,51 @@ +import ScrollContainer from 'react-indiana-drag-scroll'; +import styled from 'styled-components'; + +export const ProductListGroup = styled.div` + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; +`; + +export const SelectedProductsHeader = styled.div` + box-sizing: border-box; + display: flex; + flex-direction: row; + justify-content: space-between; + width: 100%; + padding-right: 20px; + padding-left: 20px; +`; + +export const Title = styled.p` + font-family: ${(props) => props.theme.fonts}; + font-size: 12px; + font-weight: 800; + line-height: 16px; + color: var(--black-plus); +`; + +export const ProductCardWrapper = styled.div``; + +export const DeleteButton = styled.button` + font-family: ${(props) => props.theme.fonts}; + font-size: 12px; + font-weight: 800; + line-height: 16px; + color: var(--red-plus); + cursor: pointer; + background-color: transparent; + + &:hover { + opacity: 0.7; + } +`; + +export const SelectedProductsList = styled(ScrollContainer)` + display: flex; + gap: 10px; + padding-right: 20px; + padding-left: 20px; + user-select: none; +`; diff --git a/src/components/cart/ui/radio-cart-button/index.ts b/src/components/cart/ui/radio-cart-button/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..2c531400cd9c652ddaeff29f28851c64f370f57e --- /dev/null +++ b/src/components/cart/ui/radio-cart-button/index.ts @@ -0,0 +1 @@ +export * from './radio-cart-button'; diff --git a/src/components/cart/ui/radio-cart-button/radio-cart-button.tsx b/src/components/cart/ui/radio-cart-button/radio-cart-button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b8e097ea21a8b420f3e27e9aba1c3fd26ac536a5 --- /dev/null +++ b/src/components/cart/ui/radio-cart-button/radio-cart-button.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { FC } from 'react'; +import { Control, Controller } from 'react-hook-form'; + +import { Radio, RadioActive } from '@/shared'; + +import * as Styled from './styled'; + +interface IRadioProps { + label: string; + value: string; + activeValue: string; + colorItem?: string; + control: Control; +} + +export const RadioCartButton: FC = ({ + label, + value, + activeValue, + control, + colorItem, +}) => ( + + {activeValue === value ? : } + ( + onChange(value)} + /> + )} + /> + {label} + +); diff --git a/src/components/cart/ui/radio-cart-button/styled.tsx b/src/components/cart/ui/radio-cart-button/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..802973f9745effa4790f1556b3145471487e7154 --- /dev/null +++ b/src/components/cart/ui/radio-cart-button/styled.tsx @@ -0,0 +1,24 @@ +import styled from 'styled-components'; + +interface StyledRadioProps { + $colorItem?: string; +} + +export const StyledRadioLabel = styled.label` + display: inline-flex; + gap: 6px; + align-items: center; + font-size: 12px; + font-weight: 800; + line-height: 16px; + color: ${({ $colorItem }) => $colorItem || 'black'}; + cursor: pointer; +`; + +export const StyledRadio = styled.input` + display: none; +`; + +export const StyledSpan = styled.span` + color: var(--black-plus); +`; diff --git a/src/components/cart/ui/radio-with-text/index.ts b/src/components/cart/ui/radio-with-text/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..382c2fb8ad05a6bbee22b6865207776b22597dbd --- /dev/null +++ b/src/components/cart/ui/radio-with-text/index.ts @@ -0,0 +1 @@ +export * from './radio-with-text'; diff --git a/src/components/cart/ui/radio-with-text/radio-with-text.tsx b/src/components/cart/ui/radio-with-text/radio-with-text.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ac6a163755d6f5d30465eafe1c940314f4030cd7 --- /dev/null +++ b/src/components/cart/ui/radio-with-text/radio-with-text.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { FC } from 'react'; +import { Control, Controller } from 'react-hook-form'; + +import * as Styled from './styled'; + +interface IRadioWithText { + colorItem: string; + value: string; + activeValue: string; + control: Control; +} + +export const RadioWithText: FC = ({ + colorItem, + value, + control, + activeValue, +}) => ( + <> + ( + onChange(e.currentTarget.value)} + /> + )} + /> + {value} + +); diff --git a/src/components/cart/ui/radio-with-text/styled.tsx b/src/components/cart/ui/radio-with-text/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..88d8f66e19ab67971aa09ed101d8f17bcfaf489d --- /dev/null +++ b/src/components/cart/ui/radio-with-text/styled.tsx @@ -0,0 +1,31 @@ +import styled from 'styled-components'; + +interface IRadioLabelProps { + $colorItem?: string; +} + +export const RadioLabel = styled.label` + display: inline-flex; + gap: 6px; + align-items: center; + padding: 6px 12px; + margin-bottom: 4px; + font-size: 12px; + font-weight: 800; + line-height: 16px; + color: var(--black-plus); + white-space: nowrap; + cursor: pointer; + border: 2px solid var(--black-plus); + border-radius: 10px; + box-shadow: 2px 2px 2px rgb(0 0 0 / 25%); +`; + +export const RadioWithText = styled.input` + display: none; + + &:checked + ${RadioLabel} { + color: var(--white); + background-color: ${({ $colorItem }) => $colorItem || 'black'}; + } +`; diff --git a/src/components/catalog/catalog.tsx b/src/components/catalog/catalog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f2e288d664e232168f277bd6b45d1a8f9c2fcff3 --- /dev/null +++ b/src/components/catalog/catalog.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { FC } from 'react'; + +import { MenuCategory } from '@/interfaces'; + +import { CatalogTabs, CatalogCategoriesList } from './ui'; +import * as Styled from './styled'; + +interface ICatalogProps { + shopName: string; + categories: MenuCategory[]; + colorItem?: string; + selectedTab: number; + handleTabClick: (id: number) => void; +} + +export const Catalog: FC = ({ + shopName, + categories, + colorItem, + selectedTab, + handleTabClick, +}) => ( + + + + +); diff --git a/src/components/catalog/index.ts b/src/components/catalog/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d435521dc90900cf9d73f05bc7f1051ea3f9a339 --- /dev/null +++ b/src/components/catalog/index.ts @@ -0,0 +1 @@ +export * from './catalog'; diff --git a/src/components/catalog/styled.tsx b/src/components/catalog/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..22fdc413823c718c5ef4d1aa5141f95664be962d --- /dev/null +++ b/src/components/catalog/styled.tsx @@ -0,0 +1,20 @@ +import styled from 'styled-components'; + +import { devices } from '@/styles'; + +export const Catalog = styled.div` + position: sticky; + height: 100%; + padding-top: 12px; + -ms-overflow-style: none; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + + @media ${devices.tablet} { + position: static; + min-height: 100vh; + } +`; diff --git a/src/components/catalog/ui/catalog-categories-list/catalog-categories-list.tsx b/src/components/catalog/ui/catalog-categories-list/catalog-categories-list.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e91253086ff082fb492304319d0915f4ab1f00ae --- /dev/null +++ b/src/components/catalog/ui/catalog-categories-list/catalog-categories-list.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { FC } from 'react'; + +import { MenuCategory } from '@/interfaces'; + +import { CatalogCategory } from '../catalog-category'; +import * as Styled from './styled'; + +interface CatalogCategoriesListProps { + categories: MenuCategory[]; + selectedTab: number | null; + colorItem?: string; + shopName: string; +} + +export const CatalogCategoriesList: FC = ({ + categories, + selectedTab, + colorItem = 'var(--green-minus)', + shopName, +}) => ( + + {categories.map( + (category, index) => + selectedTab === index && + category.lists.map((menuList, categoryIndex) => ( + + )), + )} + +); diff --git a/src/components/catalog/ui/catalog-categories-list/index.ts b/src/components/catalog/ui/catalog-categories-list/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..1467f8011020eaa646a0b61245594eed17abebfa --- /dev/null +++ b/src/components/catalog/ui/catalog-categories-list/index.ts @@ -0,0 +1 @@ +export * from './catalog-categories-list'; diff --git a/src/components/catalog/ui/catalog-categories-list/styled.tsx b/src/components/catalog/ui/catalog-categories-list/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c7ef2b432e99587370b87bc5c8899a2427bb9ec4 --- /dev/null +++ b/src/components/catalog/ui/catalog-categories-list/styled.tsx @@ -0,0 +1,11 @@ +import styled from 'styled-components'; + +export const CatalogCategoryList = styled.ul` + top: 46px; + display: flex; + flex-direction: column; + gap: 12px; + height: calc(100vh - 200px); + padding-top: 14px; + overflow-y: auto; +`; diff --git a/src/components/catalog/ui/catalog-category/catalog-category.tsx b/src/components/catalog/ui/catalog-category/catalog-category.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8272ba1a2dfdf619b96d876ec9fc26c16f063c94 --- /dev/null +++ b/src/components/catalog/ui/catalog-category/catalog-category.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { FC, useState } from 'react'; + +import { Icon } from '@/shared'; +import { MenuList } from '@/interfaces'; + +import { ProductCardLink } from '../../../product-card-link'; +import { ProductCardTopContent } from '../../../product-card-top-content'; +import { ProductCardBottomContent } from '../../../product-card-bottom-content'; +import { ProductCardButton } from '../product-card-button'; +import * as Styled from './styled'; + +interface CatalogCategoryProps { + menuList: MenuList; + colorItem?: string; + selectedTab: number | null; + categoryIndex: number; + shopName: string; +} + +export const CatalogCategory: FC = ({ + menuList, + colorItem = 'var(--green-minus)', + selectedTab, + categoryIndex, + shopName, +}) => { + const [showAllProducts, setShowAllProducts] = useState( + menuList.items.length < 5, + ); + const handleShowAllProducts = (): void => { + setShowAllProducts((prev) => !prev); + }; + const itemsToDisplay = showAllProducts + ? menuList.items + : menuList.items.slice(0, 3); + + return ( + + + {menuList.name} + + + + + + {itemsToDisplay.map((item) => ( + } + to={`/catalog/${shopName}/product/${item.slug}`} + topContent={ + + } + /> + ))} + {!showAllProducts && ( + + 6 из {menuList.items.length} + + } + topContent={ + + Посмотреть все + + } + onClick={handleShowAllProducts} + /> + )} + + + ); +}; diff --git a/src/components/catalog/ui/catalog-category/index.ts b/src/components/catalog/ui/catalog-category/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..7201fb9b398715cc75c81bdf0658d5709d480484 --- /dev/null +++ b/src/components/catalog/ui/catalog-category/index.ts @@ -0,0 +1 @@ +export * from './catalog-category'; diff --git a/src/components/catalog/ui/catalog-category/styled.tsx b/src/components/catalog/ui/catalog-category/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..af2975fb94f103b96e4728f9850d69645e24c2b1 --- /dev/null +++ b/src/components/catalog/ui/catalog-category/styled.tsx @@ -0,0 +1,70 @@ +import Link from 'next/link'; +import ScrollContainer from 'react-indiana-drag-scroll'; +import styled from 'styled-components'; + +export const CatalogCategory = styled.li` + display: flex; + flex-direction: column; + width: 100%; +`; + +export const IconWrapper = styled.div` + width: 24px; + transform: rotate(-90deg); + + &:hover { + opacity: 0.7; + } +`; + +export const CategoryDetails = styled(Link)` + display: flex; + flex-direction: row; + justify-content: space-between; + padding-right: 20px; + padding-bottom: 12px; + padding-left: 20px; + cursor: pointer; + + &:hover { + ${IconWrapper} { + opacity: 0.5; + } + } +`; + +export const CategoryTitle = styled.h3` + font-family: ${(props) => props.theme.fonts}; + font-size: 14px; + font-weight: 800; + line-height: 20px; + color: var(--black); +`; + +export const CategoryProducts = styled(ScrollContainer)` + display: flex; + flex-direction: row; + gap: 12px; + padding-right: 20px; + padding-left: 20px; +`; + +export const CategoryProductMoreTop = styled.p` + padding: 10px 8px; + font-family: ${(props) => props.theme.fonts}; + font-size: 12px; + font-weight: 800; + line-height: 16px; + color: var(--gray); + white-space: nowrap; +`; + +export const CategoryProductMoreBottom = styled.p` + padding: 4px 10px 2px; + font-family: ${(props) => props.theme.fonts}; + font-size: 10px; + font-weight: 600; + line-height: 14px; + color: var(--white); + text-align: left; +`; diff --git a/src/components/catalog/ui/catalog-tabs/catalog-tabs.tsx b/src/components/catalog/ui/catalog-tabs/catalog-tabs.tsx new file mode 100644 index 0000000000000000000000000000000000000000..182bfc64f5b787cb98b73eab614fe4641ff6f22b --- /dev/null +++ b/src/components/catalog/ui/catalog-tabs/catalog-tabs.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { FC } from 'react'; + +import { Tab } from '@/shared'; +import { MenuCategory } from '@/interfaces'; + +import * as Styled from './styled'; + +interface CatalogTabProps { + categories: MenuCategory[]; + selectedTab: number; + onClick: (id: number) => void; + colorItem?: string; +} + +export const CatalogTabs: FC = ({ + categories, + selectedTab, + onClick, + colorItem, +}) => ( + + {categories.map((category, index) => ( + onClick(index)} + /> + ))} + +); diff --git a/src/components/catalog/ui/catalog-tabs/index.ts b/src/components/catalog/ui/catalog-tabs/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..1397848ecbd38082ce0bcd604ddf09baa106bc4d --- /dev/null +++ b/src/components/catalog/ui/catalog-tabs/index.ts @@ -0,0 +1 @@ +export * from './catalog-tabs'; diff --git a/src/components/catalog/ui/catalog-tabs/styled.tsx b/src/components/catalog/ui/catalog-tabs/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..490fe5da544710fa00308c3fa3220ecb69ca7bcc --- /dev/null +++ b/src/components/catalog/ui/catalog-tabs/styled.tsx @@ -0,0 +1,12 @@ +import ScrollContainer from 'react-indiana-drag-scroll'; +import 'react-indiana-drag-scroll/dist/style.css'; +import styled from 'styled-components'; + +export const CatalogTabs = styled(ScrollContainer)` + display: flex; + flex-direction: row; + gap: 20px; + padding-right: 20px; + padding-left: 20px; + border-bottom: 1px solid var(--gray); +`; diff --git a/src/components/catalog/ui/index.ts b/src/components/catalog/ui/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..6e957f4fdd416de81ece44084e598c84ba3cc945 --- /dev/null +++ b/src/components/catalog/ui/index.ts @@ -0,0 +1,4 @@ +export * from './catalog-categories-list'; +export * from './catalog-category'; +export * from './catalog-tabs'; +export * from './product-card-button'; diff --git a/src/components/catalog/ui/product-card-button/index.ts b/src/components/catalog/ui/product-card-button/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..ccfe85375cdea15dbf679e917c6fe96d00b33328 --- /dev/null +++ b/src/components/catalog/ui/product-card-button/index.ts @@ -0,0 +1 @@ +export * from './product-card-button'; diff --git a/src/components/catalog/ui/product-card-button/product-card-button.tsx b/src/components/catalog/ui/product-card-button/product-card-button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..95aa97cb7b1efb9577c5c7399c4fe41386ada589 --- /dev/null +++ b/src/components/catalog/ui/product-card-button/product-card-button.tsx @@ -0,0 +1,20 @@ +import { FC, ReactNode } from 'react'; + +import { Card } from '@/shared'; + +import * as Styled from './styled'; + +interface ProductCardButtonProps { + topContent?: ReactNode; + bottomContent?: ReactNode; + onClick: () => void; +} +export const ProductCardButton: FC = ({ + topContent, + bottomContent, + onClick, +}) => ( + + + +); diff --git a/src/components/catalog/ui/product-card-button/styled.tsx b/src/components/catalog/ui/product-card-button/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4a24f06cc1ef26bfcec016a80ba9078d3ec74260 --- /dev/null +++ b/src/components/catalog/ui/product-card-button/styled.tsx @@ -0,0 +1,5 @@ +import styled from 'styled-components'; + +export const ProductCardButton = styled.button` + background-color: transparent; +`; diff --git a/src/components/category/categoty.tsx b/src/components/category/categoty.tsx new file mode 100644 index 0000000000000000000000000000000000000000..21413eb301ee841b0d26fa20dca5e468f4ff803d --- /dev/null +++ b/src/components/category/categoty.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { FC } from 'react'; + +import { MenuList } from '@/interfaces'; +import { BackGroup } from '@/shared'; + +import * as Styled from './styled'; +import { CategoryProductsList } from './ui'; + +interface ICategoryProps { + category: MenuList | null; + shop: string; + handleBackClick: () => void; + colorItem: string; +} + +export const Category: FC = ({ + category, + shop, + handleBackClick, + colorItem, +}) => ( + + + + + + +); diff --git a/src/components/category/index.ts b/src/components/category/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..70da7785df56aad80b261045cc462fc2de659245 --- /dev/null +++ b/src/components/category/index.ts @@ -0,0 +1 @@ +export * from './categoty'; diff --git a/src/components/category/styled.tsx b/src/components/category/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3ff300e76dd469c7273effeea7335bc802d2e819 --- /dev/null +++ b/src/components/category/styled.tsx @@ -0,0 +1,45 @@ +import styled from 'styled-components'; + +import { devices } from '@/styles'; + +interface CategoryPageProps { + $colorItem?: string; +} + +export const CategoryPage = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + height: 897px; + overflow-y: auto; + background-color: ${({ $colorItem }) => $colorItem || 'var(--black)'}; + -ms-overflow-style: none; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } + + @media ${devices.desktopMd} { + height: 789px; + } + + @media ${devices.desktop} { + width: 361px; + height: 656px; + } + + @media ${devices.tablet} { + width: 100%; + height: 100vh; + } +`; + +export const CategoryHeaderWrapper = styled.div` + display: flex; + flex-direction: row; + gap: 12px; + align-items: center; + justify-content: flex-start; + margin: 20px; +`; diff --git a/src/components/category/ui/CategoryProductImage/CategoryProductImage.tsx b/src/components/category/ui/CategoryProductImage/CategoryProductImage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8b92e8e99a67fa79ce760c6c0278deb90e6ce48d --- /dev/null +++ b/src/components/category/ui/CategoryProductImage/CategoryProductImage.tsx @@ -0,0 +1,13 @@ +import { FC } from 'react'; + +import * as Styled from './styled'; + +interface CategoryProductImageProps { + src: string; + alt: string; +} + +export const CategoryProductImage: FC = ({ + src, + alt, +}) => ; diff --git a/src/components/category/ui/CategoryProductImage/index.ts b/src/components/category/ui/CategoryProductImage/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b8c6524907da765d0f2d629aa6e5106610d0cf74 --- /dev/null +++ b/src/components/category/ui/CategoryProductImage/index.ts @@ -0,0 +1 @@ +export * from './CategoryProductImage'; diff --git a/src/components/category/ui/CategoryProductImage/styled.tsx b/src/components/category/ui/CategoryProductImage/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..92cd3b8bed02171b568b73a1032d2932417e836d --- /dev/null +++ b/src/components/category/ui/CategoryProductImage/styled.tsx @@ -0,0 +1,8 @@ +import styled from 'styled-components'; + +export const CategoryProductImage = styled.img` + width: 100%; + height: auto; + object-fit: cover; + border-radius: 15px; +`; diff --git a/src/components/category/ui/CategoryProductsList/CategoryProductsList.tsx b/src/components/category/ui/CategoryProductsList/CategoryProductsList.tsx new file mode 100644 index 0000000000000000000000000000000000000000..357cbe1ff9da80a15af9bacbb35998e9b0ee17ec --- /dev/null +++ b/src/components/category/ui/CategoryProductsList/CategoryProductsList.tsx @@ -0,0 +1,36 @@ +import { FC } from 'react'; + +import { MenuItem } from '@/interfaces'; + +import { ProductCardCategoryBottomContent } from '../ProductCardCategoryBottomContent'; +import { CategoryProductImage } from '../CategoryProductImage'; +import * as Styled from './styled'; +import { ProductCardLink } from '../../../product-card-link'; + +const coffee = '/coffee.jpg'; + +interface ICategoryProductsListProps { + products: MenuItem[]; + shop: string; +} + +export const CategoryProductsList: FC = ({ + products, + shop, +}) => ( + + {products.map((product) => ( + } + bottomContent={ + + } + /> + ))} + +); diff --git a/src/components/category/ui/CategoryProductsList/index.ts b/src/components/category/ui/CategoryProductsList/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..9541c89072c4b31433e0203bdd52c1f5933e1cbc --- /dev/null +++ b/src/components/category/ui/CategoryProductsList/index.ts @@ -0,0 +1 @@ +export * from './CategoryProductsList'; diff --git a/src/components/category/ui/CategoryProductsList/styled.tsx b/src/components/category/ui/CategoryProductsList/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c75cab90e93fbd5f82cca7cf323b48ebe63f7938 --- /dev/null +++ b/src/components/category/ui/CategoryProductsList/styled.tsx @@ -0,0 +1,24 @@ +import styled from 'styled-components'; + +import { devices } from '@/styles'; + +export const CategoryProductsList = styled.div` + box-sizing: border-box; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(130px, 47%)); + grid-auto-rows: minmax(140px, auto); + gap: 12px; + justify-content: flex-start; + width: 100%; + padding-right: 20px; + padding-bottom: 110px; + padding-left: 20px; + + @media ${devices.tablet} { + grid-template-columns: repeat(auto-fit, minmax(130px, 32%)); + } + + @media ${devices.mobile} { + grid-template-columns: repeat(auto-fit, minmax(130px, 47%)); + } +`; diff --git a/src/components/category/ui/ProductCardCategoryBottomContent/ProductCardCategoryBottomContent.tsx b/src/components/category/ui/ProductCardCategoryBottomContent/ProductCardCategoryBottomContent.tsx new file mode 100644 index 0000000000000000000000000000000000000000..01da66399dbf55eed1c3ef2175d4ee774baf5342 --- /dev/null +++ b/src/components/category/ui/ProductCardCategoryBottomContent/ProductCardCategoryBottomContent.tsx @@ -0,0 +1,17 @@ +import { FC } from 'react'; + +import * as Styled from './styled'; + +interface ProductCardCategoryBottomContentProps { + name: string; + price: string; +} + +export const ProductCardCategoryBottomContent: FC< + ProductCardCategoryBottomContentProps +> = ({ name, price }) => ( + + {name} + {price} + +); diff --git a/src/components/category/ui/ProductCardCategoryBottomContent/index.ts b/src/components/category/ui/ProductCardCategoryBottomContent/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..737bc06e08c76ccd3a8adfa8f22447f64bbaa2f2 --- /dev/null +++ b/src/components/category/ui/ProductCardCategoryBottomContent/index.ts @@ -0,0 +1 @@ +export * from './ProductCardCategoryBottomContent'; diff --git a/src/components/category/ui/ProductCardCategoryBottomContent/styled.tsx b/src/components/category/ui/ProductCardCategoryBottomContent/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a1e31ed3f8421537659a6fd5683a991717e18bfa --- /dev/null +++ b/src/components/category/ui/ProductCardCategoryBottomContent/styled.tsx @@ -0,0 +1,33 @@ +import styled from 'styled-components'; + +export const ProductCardCategoryBottomContent = styled.div` + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; + padding: 2px 12px 6px; +`; + +export const ProductTitle = styled.p` + display: -webkit-box; + overflow: hidden; + font-family: Gilroy, sans-serif; + font-size: 10px; + font-weight: 800; + line-height: 14px; + color: var(--white); + text-align: left; + text-overflow: ellipsis; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; +`; + +export const ProductPrice = styled.p` + font-family: Gilroy, sans-serif; + font-size: 9px; + font-weight: 800; + line-height: 12px; + color: var(--white); + text-align: right; +`; diff --git a/src/components/category/ui/index.ts b/src/components/category/ui/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..9541c89072c4b31433e0203bdd52c1f5933e1cbc --- /dev/null +++ b/src/components/category/ui/index.ts @@ -0,0 +1 @@ +export * from './CategoryProductsList'; diff --git a/src/components/login/index.ts b/src/components/login/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..6cc1e6e20bcb05bd8b2cdd4528ce8359c545e6ad --- /dev/null +++ b/src/components/login/index.ts @@ -0,0 +1 @@ +export * from './login'; diff --git a/src/components/login/login.tsx b/src/components/login/login.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5cb1491249b3823f99b986749f97db517f282265 --- /dev/null +++ b/src/components/login/login.tsx @@ -0,0 +1,132 @@ +'use client'; + +import { Controller, SubmitHandler, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import classNames from 'classnames'; +import { PatternFormat } from 'react-number-format'; +import { useState } from 'react'; +import { AnimatePresence } from 'framer-motion'; +import { isAxiosError } from 'axios'; +import { useRouter } from 'next/navigation'; + +import { useLoginMutation } from '@/api-hooks'; +import { + ActionAuthButton, + BackGroup, + LayoutModal, + Overlay, + Portal, +} from '@/shared'; +import { ILoginResponse } from '@/interfaces'; +import { useAppDispatch } from '@/hooks'; +import { setToken, setUser } from '@/store'; + +import styled from './styles/input.module.css'; +import * as Styled from './styled'; +import { LoginSchema, TLoginSchema } from './schema'; +import { AuthErrorModal } from './ui'; + +export const Login = () => { + const router = useRouter(); + const dispatch = useAppDispatch(); + const [localError, setLocalError] = useState(null); + + const handleBackClick = () => { + router.push('/'); + }; + + const { control, handleSubmit, reset } = useForm({ + resolver: zodResolver(LoginSchema), + defaultValues: { + phone: '', + }, + }); + + const handleSuccess = (data: ILoginResponse) => { + if (data?.success) { + dispatch(setUser({ data: data.user })); + dispatch(setToken({ data: data.accessToken })); + router.replace('/profile'); + } + }; + + const handleError = (error: Error) => { + if (isAxiosError(error) && error.code === '404') router.replace('/404'); + if (isAxiosError(error) && error.code === '500') + router.replace('/server-error'); + setLocalError(error); + }; + + const { mutate, isPending } = useLoginMutation(handleError, handleSuccess); + + const onSubmit: SubmitHandler = (dataForm) => { + mutate(dataForm); + }; + + const handleCloseModal = () => { + reset(); + setLocalError(null); + }; + + return ( + + + + + + Введите номер + + ( + + + {error?.message && ( + {error.message} + )} + + )} + /> + + + + + + + + {localError && ( + + + + + + + + )} + + + ); +}; diff --git a/src/components/login/schema/index.ts b/src/components/login/schema/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..1470562bab9b4a8bc851516cc2670378de9d6588 --- /dev/null +++ b/src/components/login/schema/index.ts @@ -0,0 +1 @@ +export * from './schema-login-form'; diff --git a/src/components/login/schema/schema-login-form.ts b/src/components/login/schema/schema-login-form.ts new file mode 100644 index 0000000000000000000000000000000000000000..739555701c4f8cb0357521d95490c724aa92e217 --- /dev/null +++ b/src/components/login/schema/schema-login-form.ts @@ -0,0 +1,13 @@ +import { isValidPhoneNumber } from 'libphonenumber-js'; +import { ZodType, z } from 'zod'; + +export const LoginSchema: ZodType = z.object({ + phone: z + .string() + .transform((val) => val.replace(/[\s()-]/g, '')) + .refine((val) => isValidPhoneNumber(val, 'RU'), { + message: 'Неверный номер телефона (RU).', + }), +}); + +export type TLoginSchema = z.infer; diff --git a/src/components/login/styled.tsx b/src/components/login/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3ebdde4d2df4b9cfce0eeece93548fc973b8c4e6 --- /dev/null +++ b/src/components/login/styled.tsx @@ -0,0 +1,84 @@ +import styled from 'styled-components'; + +import { devices } from '@/styles'; + +export const Login = styled.div` + box-sizing: border-box; + width: 100%; + height: 100%; + padding: 20px; + background-color: var(--violet); +`; + +export const FormWrapper = styled.div` + box-sizing: border-box; + display: flex; + align-items: flex-start; + width: 100%; + height: 100%; + padding: 64px 28px 0; + background-color: var(--violet); + + @media ${devices.tablet} { + justify-content: center; + padding: 0; + padding-top: 20vh; + } +`; + +export const LoginForm = styled.form` + position: relative; + display: flex; + flex-direction: column; + gap: 28px; + align-items: center; + width: 100%; + max-width: 328px; + padding: 34px; + background-color: var(--white); + border: 2px solid var(--black-plus); + border-radius: 15px; + box-shadow: 0 4px 4px rgb(0 0 0 / 25%); +`; + +export const Fieldset = styled.fieldset` + display: flex; + flex-direction: column; + gap: 20px; + width: 100%; +`; + +export const AuthInputWrapper = styled.div` + position: relative; +`; + +export const Title = styled.p` + align-self: flex-start; + font-family: ${(props) => props.theme.fonts}; + font-size: 14px; + font-weight: 800; + line-height: 20px; + color: var(--black-plus); +`; + +export const GoButtonWrapper = styled.div` + position: absolute; + right: 22px; + bottom: -22px; + box-shadow: 0 4px 4px rgb(0 0 0 / 25%); +`; + +export const InputError = styled.span` + position: absolute; + top: 26px; + left: 0; + display: block; + width: 300px; + overflow: hidden; + font-size: 12px; + font-weight: 400; + line-height: 12px; + color: var(--red); + text-overflow: ellipsis; + white-space: nowrap; +`; diff --git a/src/components/login/styles/input.module.css b/src/components/login/styles/input.module.css new file mode 100644 index 0000000000000000000000000000000000000000..fbcb5c8e3638f18bf8b92c13ebe03f2e59035481 --- /dev/null +++ b/src/components/login/styles/input.module.css @@ -0,0 +1,22 @@ +.input { + color: var(--black-plus); + border-bottom: 2px solid var(--black-plus); + font-size: 14px; + font-weight: 800; + line-height: 20px; + padding-bottom: 2px; + width: 100%; + background-color: transparent; + + &:focus { + outline: none; + } + + &::placeholder { + color: var(--gray-plus); + } +} + +.inputError { + border-bottom: 2px solid var(--red); +} \ No newline at end of file diff --git a/src/components/login/ui/error-modal/error-modal.tsx b/src/components/login/ui/error-modal/error-modal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..008e0722e553722d67b2be64d3eb98b3bb8a0389 --- /dev/null +++ b/src/components/login/ui/error-modal/error-modal.tsx @@ -0,0 +1,33 @@ +import { FC } from 'react'; + +import { Modal } from '@/shared'; + +import { ModalContent } from '../modal-content'; + +interface IAuthErrorModal { + handleClose: () => void; + message: string; +} + +export const AuthErrorModal: FC = ({ + handleClose, + message, +}) => { + const content = ( + + ); + + return ( + + ); +}; diff --git a/src/components/login/ui/error-modal/index.ts b/src/components/login/ui/error-modal/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..2ac788bc98dc368e3dc32156dfcea2355b348447 --- /dev/null +++ b/src/components/login/ui/error-modal/index.ts @@ -0,0 +1 @@ +export * from './error-modal'; diff --git a/src/components/login/ui/error-modal/styled.tsx b/src/components/login/ui/error-modal/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0230ce03604b55221e93de37f02f2cad4edbe765 --- /dev/null +++ b/src/components/login/ui/error-modal/styled.tsx @@ -0,0 +1,9 @@ +import styled from 'styled-components'; + +export const ErrorModal = styled.div` + display: flex; + flex-direction: column; + gap: 7px; + align-items: center; + width: 100%; +`; diff --git a/src/components/login/ui/index.ts b/src/components/login/ui/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..2ac788bc98dc368e3dc32156dfcea2355b348447 --- /dev/null +++ b/src/components/login/ui/index.ts @@ -0,0 +1 @@ +export * from './error-modal'; diff --git a/src/components/login/ui/modal-content/index.ts b/src/components/login/ui/modal-content/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..2c211af88f75c306576180c974b303bcd55d0458 --- /dev/null +++ b/src/components/login/ui/modal-content/index.ts @@ -0,0 +1 @@ +export * from './modal-content'; diff --git a/src/components/login/ui/modal-content/modal-content.tsx b/src/components/login/ui/modal-content/modal-content.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f1145c8cdcfc6718e943ca71e835c0affefcb15e --- /dev/null +++ b/src/components/login/ui/modal-content/modal-content.tsx @@ -0,0 +1,25 @@ +import { FC } from 'react'; + +import * as Styled from './styled'; + +interface IModalContent { + handleClose: () => void; + btnColor: string; + text: string; + subtitle: string; +} + +export const ModalContent: FC = ({ + handleClose, + btnColor, + text, + subtitle, +}) => ( + <> + {text} + {subtitle} + + Ok + + +); diff --git a/src/components/login/ui/modal-content/styled.tsx b/src/components/login/ui/modal-content/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1bf99004b175f240efe31fcecfd48fc77b5fa8b5 --- /dev/null +++ b/src/components/login/ui/modal-content/styled.tsx @@ -0,0 +1,70 @@ +import styled from 'styled-components'; + +import { devices } from '@/styles'; + +export const MessageText = styled.p` + font-family: ${(props) => props.theme.fonts}; + font-size: 18px; + font-weight: 800; + line-height: 26px; + color: var(--black-plus); + + @media ${devices.tablet} { + font-size: 20px; + line-height: 28px; + } + + @media ${devices.mobile} { + font-size: 18px; + line-height: 26px; + } +`; + +export const MessageSubtitle = styled.p` + font-family: ${(props) => props.theme.fonts}; + font-size: 13px; + font-weight: 800; + line-height: 16px; + color: var(--gray); + + @media ${devices.tablet} { + font-size: 16px; + line-height: 22px; + } + + @media ${devices.mobile} { + font-size: 13px; + line-height: 16px; + } +`; + +export const PaymentCheckButton = styled.button<{ $btnColor: string }>` + position: absolute; + right: 22px; + bottom: -20px; + display: inline-block; + padding: 12px 30px; + font-size: 14px; + font-weight: 800; + line-height: 20px; + color: var(--white); + text-align: center; + letter-spacing: 0; + cursor: pointer; + user-select: none; + background-color: var(${({ $btnColor }) => $btnColor}); + border: 2px solid var(--black-plus); + border-radius: 5px; + box-shadow: 2px 2px 2px rgb(0 0 0 / 25%); + transition: 0.2s ease; + + &:hover { + box-shadow: 3px 3px 8px rgb(0 0 0 / 30%); + transform: translateY(-1px); + } + + &:active { + box-shadow: initial; + transform: translateY(0); + } +`; diff --git a/src/components/product-card-bottom-content/index.ts b/src/components/product-card-bottom-content/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd57dc570241f7f658f0f6c6a0d1aec9654466b0 --- /dev/null +++ b/src/components/product-card-bottom-content/index.ts @@ -0,0 +1 @@ +export * from './product-card-bottom-content'; diff --git a/src/components/product-card-bottom-content/product-card-bottom-content.tsx b/src/components/product-card-bottom-content/product-card-bottom-content.tsx new file mode 100644 index 0000000000000000000000000000000000000000..64d1347286781143db6696c84ea55bedb3a9978b --- /dev/null +++ b/src/components/product-card-bottom-content/product-card-bottom-content.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { FC } from 'react'; + +import { formatPrice, parseToInt } from '@/utils'; + +import * as Styled from './styled'; + +interface ProductCardBottomContentProps { + price: string; +} +export const ProductCardBottomContent: FC = ({ + price, +}) => ( + + {formatPrice(parseToInt(price))} + +); diff --git a/src/components/product-card-bottom-content/styled.tsx b/src/components/product-card-bottom-content/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d8b5fa327181bccebbfd430907b9bc515a83034c --- /dev/null +++ b/src/components/product-card-bottom-content/styled.tsx @@ -0,0 +1,15 @@ +import styled from 'styled-components'; + +export const ProductCardBottomContent = styled.div` + display: flex; + justify-content: flex-end; + padding: 4px 10px 2px; +`; + +export const Price = styled.span` + font-family: ${(props) => props.theme.fonts}; + font-size: 10px; + font-weight: 600; + line-height: 14px; + color: var(--white); +`; diff --git a/src/components/product-card-link/index.ts b/src/components/product-card-link/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c0f7f44209cf2cdb7a4e29632de79c37937ee36 --- /dev/null +++ b/src/components/product-card-link/index.ts @@ -0,0 +1 @@ +export * from './product-card-link'; diff --git a/src/components/product-card-link/product-card-link.tsx b/src/components/product-card-link/product-card-link.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d82cd7e9545718177596f37cced8e51d2b760fff --- /dev/null +++ b/src/components/product-card-link/product-card-link.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { FC, ReactNode } from 'react'; + +import { Card } from '@/shared'; + +import * as Styled from './styled'; + +interface IProductCardLinkProps { + to: string; + topContent?: ReactNode; + bottomContent?: ReactNode; +} + +export const ProductCardLink: FC = ({ + to, + topContent, + bottomContent, +}) => ( + + + +); diff --git a/src/components/product-card-link/styled.tsx b/src/components/product-card-link/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ff43ae90f63cfbe764944f93938ccddb4f55f5dd --- /dev/null +++ b/src/components/product-card-link/styled.tsx @@ -0,0 +1,4 @@ +import Link from 'next/link'; +import styled from 'styled-components'; + +export const ProductCardLink = styled(Link)``; diff --git a/src/components/product-card-top-content/index.ts b/src/components/product-card-top-content/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..ee5e1aad9ad18e888a8bdbda20725108d0da5625 --- /dev/null +++ b/src/components/product-card-top-content/index.ts @@ -0,0 +1 @@ +export * from './product-card-top-content'; diff --git a/src/components/product-card-top-content/product-card-top-content.tsx b/src/components/product-card-top-content/product-card-top-content.tsx new file mode 100644 index 0000000000000000000000000000000000000000..460199f511e6578c1ba3b8620fea41eb7ded0806 --- /dev/null +++ b/src/components/product-card-top-content/product-card-top-content.tsx @@ -0,0 +1,25 @@ +'use client'; + +import { FC } from 'react'; + +import { Icon } from '@/shared'; + +import * as Styled from './styled'; + +interface ProductCardTopContentProps { + name: string; + icon: string; + colorItem?: string; +} +export const ProductCardTopContent: FC = ({ + name, + icon, + colorItem, +}) => ( + + + + + {name} + +); diff --git a/src/components/product-card-top-content/styled.tsx b/src/components/product-card-top-content/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..96d0fc5addd929cba5eecf928fafd7cd1085beac --- /dev/null +++ b/src/components/product-card-top-content/styled.tsx @@ -0,0 +1,32 @@ +import styled from 'styled-components'; + +export const ProductCardTopContent = styled.div` + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; + width: max-content; + padding-right: 8px; + padding-left: 8px; +`; + +export const IconWrapper = styled.div` + padding-top: 6px; + padding-bottom: 6px; +`; + +export const Title = styled.p` + display: -webkit-box; + max-width: 183px; + padding-top: 2px; + padding-bottom: 2px; + overflow: hidden; + font-family: ${(props) => props.theme.fonts}; + font-size: 12px; + font-weight: 800; + line-height: 16px; + color: var(--black); + text-overflow: ellipsis; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +`; diff --git a/src/components/product/index.ts b/src/components/product/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..81cb0b00b92b0d7b2ddaba443899eada178f004c --- /dev/null +++ b/src/components/product/index.ts @@ -0,0 +1 @@ +export * from './product'; diff --git a/src/components/product/product.tsx b/src/components/product/product.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d22fe2ee5eb08f80b9bafa7dccc9f47cb1dbf677 --- /dev/null +++ b/src/components/product/product.tsx @@ -0,0 +1,53 @@ +'use client'; + +import { FC } from 'react'; +import { useRouter } from 'next/navigation'; + +import { + OperatingHours, + Stylebook, + IProduct, + IProductRecommendations, +} from '@/interfaces'; + +import { ProductHeader, ProductImage, ProductPageView } from './ui'; +import * as Styled from './styled'; + +interface IProductProps { + product: IProduct | null; + recommendations: IProductRecommendations[]; + shop: string; + stylebook: Stylebook | null; + mode: OperatingHours | null; +} + +export const Product: FC = ({ + product, + recommendations, + shop, + stylebook, + mode, +}) => { + const router = useRouter(); + const handleBackClick = () => { + router.push(`/catalog/${shop}/0`, { scroll: false }); + }; + + return ( + + + + + + ); +}; diff --git a/src/components/product/styled.tsx b/src/components/product/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c69fe37c1707382d149979c66579eb08f0b2da75 --- /dev/null +++ b/src/components/product/styled.tsx @@ -0,0 +1,7 @@ +import styled from 'styled-components'; + +export const ProductPage = styled.div` + display: flex; + flex-direction: column; + background-color: var(--white); +`; diff --git a/src/components/product/ui/index.ts b/src/components/product/ui/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4a864c36797185322390f6e628877eec95220d5f --- /dev/null +++ b/src/components/product/ui/index.ts @@ -0,0 +1,6 @@ +export * from './product-header'; +export * from './product-page-view'; +export * from './product-image'; +export * from './product-footer'; +export * from './product-details'; +export * from './recommendations'; diff --git a/src/components/product/ui/product-add-button/index.ts b/src/components/product/ui/product-add-button/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..7ba640c49ee2e952e029232832ad3f2283207294 --- /dev/null +++ b/src/components/product/ui/product-add-button/index.ts @@ -0,0 +1 @@ +export * from './product-add-button'; diff --git a/src/components/product/ui/product-add-button/product-add-button.tsx b/src/components/product/ui/product-add-button/product-add-button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ed9cc2fdbd6335e539df8de78ad9d4cbb758487b --- /dev/null +++ b/src/components/product/ui/product-add-button/product-add-button.tsx @@ -0,0 +1,47 @@ +import { FC } from 'react'; + +import { Icon } from '@/shared'; + +import * as Styled from './styled'; + +interface AddButtonProps { + onIncrease: () => void; + onDecrease: () => void; + value: number; + colorItem?: string; + isOpen?: boolean; +} + +export const AddButton: FC = ({ + value, + onIncrease, + onDecrease, + colorItem, + isOpen, +}) => { + const title = isOpen ? 'Добавить' : 'Закрыто'; + + if (!value || !isOpen) { + return ( + + {title} + + ); + } + + return ( + + + + + {value} + + + + + ); +}; diff --git a/src/components/product/ui/product-add-button/styled.tsx b/src/components/product/ui/product-add-button/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bd6118948531db50e80d74fb1caf75bcce0d66f9 --- /dev/null +++ b/src/components/product/ui/product-add-button/styled.tsx @@ -0,0 +1,55 @@ +import styled from 'styled-components'; + +interface AddButtonProps { + $colorItem?: string; +} + +export const AddButton = styled.button` + display: block; + width: 100%; + height: 100%; + font-size: 13px; + font-weight: 800; + line-height: 16px; + color: ${({ $colorItem }) => $colorItem || 'black'}; + letter-spacing: 0; + cursor: pointer; + user-select: none; + background-color: var(--white); + border: 2px solid var(--black-plus); + border-radius: 10px; + + &:disabled { + cursor: default; + } +`; + +export const Counter = styled.div` + box-sizing: border-box; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + height: 100%; + color: var(--green-minus); + user-select: none; + background-color: var(--white); + border: 2px solid var(--black-plus); + border-radius: 10px; +`; + +export const CounterButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + padding: 0 14px; + color: ${({ $colorItem }) => $colorItem || 'black'}; + cursor: pointer; +`; + +export const Count = styled.p` + font-family: Gilroy, sans-serif; + font-size: 12px; + font-weight: 800; + color: ${({ $colorItem }) => $colorItem || 'black'}; +`; diff --git a/src/components/product/ui/product-checkbox/index.ts b/src/components/product/ui/product-checkbox/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f61635e1ba0dfa1810b5315349831e4275d22fa8 --- /dev/null +++ b/src/components/product/ui/product-checkbox/index.ts @@ -0,0 +1 @@ +export * from './product-checkbox'; diff --git a/src/components/product/ui/product-checkbox/product-checkbox.tsx b/src/components/product/ui/product-checkbox/product-checkbox.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1d3bd41567eda735f0af7a8bfc02beac78a8e92d --- /dev/null +++ b/src/components/product/ui/product-checkbox/product-checkbox.tsx @@ -0,0 +1,39 @@ +import { FC, HTMLProps } from 'react'; + +import { Radio, RadioActive } from '@/shared'; + +import * as Styled from './styled'; + +interface CheckboxProps extends HTMLProps { + label: string; + value: string; + price: string; + name: string; + activeValues: string[]; + handleAddonChange: (value: string, price: string, checked: boolean) => void; + colorItem?: string; +} + +export const ProductCheckbox: FC = ({ + label, + value, + price, + name, + activeValues, + handleAddonChange, + colorItem, +}) => ( + + {activeValues.includes(value) ? : } + + handleAddonChange(value, price, event.target.checked) + } + /> + {label} + +); diff --git a/src/components/product/ui/product-checkbox/styled.tsx b/src/components/product/ui/product-checkbox/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0b9c1134640ffa78f055f57e0af5e71063082293 --- /dev/null +++ b/src/components/product/ui/product-checkbox/styled.tsx @@ -0,0 +1,24 @@ +import styled from 'styled-components'; + +interface StyledRadioProps { + $colorItem?: string; +} + +export const CheckboxLabel = styled.label` + display: inline-flex; + gap: 6px; + align-items: center; + font-size: 12px; + font-weight: 800; + line-height: 16px; + color: ${({ $colorItem }) => $colorItem || 'black'}; + cursor: pointer; +`; + +export const Checkbox = styled.input` + display: none; +`; + +export const StyledSpan = styled.span` + color: var(--black-plus); +`; diff --git a/src/components/product/ui/product-control/index.ts b/src/components/product/ui/product-control/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..a214e6e860287d1012da5fff58959af721e46299 --- /dev/null +++ b/src/components/product/ui/product-control/index.ts @@ -0,0 +1 @@ +export * from './product-control'; diff --git a/src/components/product/ui/product-control/product-control.tsx b/src/components/product/ui/product-control/product-control.tsx new file mode 100644 index 0000000000000000000000000000000000000000..217273ed9a5552c100f55167131b4ae152867c82 --- /dev/null +++ b/src/components/product/ui/product-control/product-control.tsx @@ -0,0 +1,38 @@ +import { formatPrice } from '@/utils'; + +import { AddButton } from '../product-add-button'; +import * as Styled from './styled'; + +export interface IProductControlProps { + isOpen: boolean; + onIncrease: () => void; + onDecrease: () => void; + count: number; + price: number; + colorItem: string; +} + +export const ProductControl = ({ + isOpen, + onIncrease, + onDecrease, + count, + price, + colorItem, +}: IProductControlProps) => ( + + + + + + + {formatPrice(count === 0 ? price : price * count)} + + +); diff --git a/src/components/product/ui/product-control/styled.tsx b/src/components/product/ui/product-control/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4b797e8be3d81a18b5be39cfda2ad003e888702e --- /dev/null +++ b/src/components/product/ui/product-control/styled.tsx @@ -0,0 +1,40 @@ +import styled from 'styled-components'; + +export const CartItemControl = styled.div` + display: grid; + grid-template-rows: 44px; + grid-template-columns: 71.5px 71.5px 67px; + border-radius: 10px; +`; + +export const AddButtonWrapper = styled.div` + z-index: 1; + grid-row: 1/2; + grid-column: 1/3; + border-radius: 10px; +`; + +export const PriceBackground = styled.div` + box-sizing: border-box; + display: flex; + grid-row: 1/2; + grid-column: 2/4; + align-items: center; + justify-content: center; + background-color: var(--black-plus); + border-top-right-radius: 10px; + border-bottom-right-radius: 10px; +`; + +export const Price = styled.div` + display: flex; + grid-row: 1/2; + grid-column: 3/4; + align-items: center; + justify-content: center; + font-family: ${(props) => props.theme.fonts}; + font-size: 12px; + font-weight: 800; + line-height: 16px; + color: var(--white); +`; diff --git a/src/components/product/ui/product-details/index.ts b/src/components/product/ui/product-details/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d415f29d5fd916c477cdb779a546b1e178ca0918 --- /dev/null +++ b/src/components/product/ui/product-details/index.ts @@ -0,0 +1 @@ +export * from './product-details'; diff --git a/src/components/product/ui/product-details/product-details.tsx b/src/components/product/ui/product-details/product-details.tsx new file mode 100644 index 0000000000000000000000000000000000000000..de25e8fcec4750f114247c6482b1a6b47216c1cc --- /dev/null +++ b/src/components/product/ui/product-details/product-details.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { FC, useState } from 'react'; + +import { + IFormState, + IModifications, + INutritionalValue, + IProductInfo, + Stylebook, +} from '@/interfaces'; +import { Tab } from '@/shared'; + +import { ProductModifications } from '../product-modifications'; +import { ProductInfo } from '../product-info'; +import * as Styled from './styled'; + +interface ProductDetailsProps { + modifications: IModifications | null; + info: IProductInfo | null; + nutritionalValue: INutritionalValue[] | null; + stylebook: Stylebook | null; + formState: IFormState; + handleAddonChange: (value: string, price: string, isChecked: boolean) => void; + handleModificationChange: (value: string) => void; +} + +export const ProductDetails: FC = ({ + modifications, + info, + nutritionalValue, + stylebook, + formState, + handleAddonChange, + handleModificationChange, +}) => { + const [selectedTab, setSelectedTab] = useState(0); + + return ( + + + setSelectedTab(0)} + /> + setSelectedTab(1)} + /> + + + {selectedTab === 0 && ( + + )} + {selectedTab === 1 && ( + + )} + + + ); +}; diff --git a/src/components/product/ui/product-details/styled.tsx b/src/components/product/ui/product-details/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e020523c4f7739a49f9ee3152a43b8330299ea84 --- /dev/null +++ b/src/components/product/ui/product-details/styled.tsx @@ -0,0 +1,33 @@ +import styled from 'styled-components'; + +import { Stylebook } from '@/interfaces'; + +interface ProductDetailsProps { + $stylebook: Stylebook | null; +} + +export const ProductDetails = styled.div` + display: flex; + flex-direction: column; + padding: 20px; +`; + +export const Tabs = styled.div` + z-index: 1; + display: flex; + flex-direction: row; + gap: 20px; + justify-content: space-evenly; + border-bottom: 1px solid var(--black); +`; + +export const ProductDetailsContent = styled.div` + z-index: 1; + display: flex; + flex-direction: column; + padding: 22px; + margin-top: 24px; + background-color: var(--white); + border-radius: 15px; + transition: max-height 1s ease; +`; diff --git a/src/components/product/ui/product-footer/index.ts b/src/components/product/ui/product-footer/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f500a3b297eed228b44d22755858784ae5999f18 --- /dev/null +++ b/src/components/product/ui/product-footer/index.ts @@ -0,0 +1 @@ +export * from './product-footer'; diff --git a/src/components/product/ui/product-footer/product-footer.tsx b/src/components/product/ui/product-footer/product-footer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..18013e18a6f0489cf9d7e106e095f50dd75ab382 --- /dev/null +++ b/src/components/product/ui/product-footer/product-footer.tsx @@ -0,0 +1,83 @@ +import { useState } from 'react'; +import { AnimatePresence } from 'framer-motion'; +import { useRouter } from 'next/navigation'; + +import { Portal, Icon, Overlay, LayoutModal } from '@/shared'; +import { Stylebook } from '@/interfaces'; +import { useAppSelector } from '@/hooks'; +import { basketSelector, loggedInSelector } from '@/store'; + +import { ProductControl, IProductControlProps } from '../product-control'; +import * as Styled from './styled'; +import { Cart } from '../../../cart'; + +interface IProductFooterProps extends IProductControlProps { + shop: string; + stylebook: Stylebook | null; +} +export const ProductFooter = ({ + isOpen, + onIncrease, + onDecrease, + count, + stylebook, + price, + shop, + colorItem, +}: IProductFooterProps) => { + const basket = useAppSelector(basketSelector); + const loggedIn = useAppSelector(loggedInSelector); + const router = useRouter(); + const isEmpty = !(basket[shop]?.items?.length > 0); + + const [modalIsOpen, setModalIsOpen] = useState(false); + const handleToPayClick = () => { + if (!loggedIn) { + router.push('/profile'); + return; + } + setModalIsOpen(true); + }; + + const handleCloseCart = () => { + setModalIsOpen(false); + }; + + return ( + + + + + К оплате + + + {modalIsOpen && ( + + + + + + + + )} + + + ); +}; diff --git a/src/components/product/ui/product-footer/styled.tsx b/src/components/product/ui/product-footer/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2b4332bb4868432f0760f4a0602c6f26ffabe35e --- /dev/null +++ b/src/components/product/ui/product-footer/styled.tsx @@ -0,0 +1,84 @@ +import styled from 'styled-components'; + +import { devices } from '@/styles'; + +export const ProductFooter = styled.div` + position: absolute; + bottom: 0; + z-index: 2; + box-sizing: border-box; + display: flex; + flex-direction: row; + justify-content: space-evenly; + width: 100%; + height: 66px; + padding-top: 12px; + padding-bottom: 10px; + padding-left: 20px; + background-color: var(--black); + border-top-left-radius: 15px; + border-top-right-radius: 15px; + + &::after { + position: absolute; + inset: 0; + pointer-events: none; + content: ''; + background: url('/patterns/noise.png'); + } + + @media ${devices.tablet} { + height: 70px; + } + + @media ${devices.mobile} { + height: 66px; + } +`; + +export const PayButton = styled.button` + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 60px; + padding: 0 5px 0 4px; + color: var(--white); + cursor: pointer; + user-select: none; + background-color: transparent; + + &:hover { + opacity: 0.8; + } + + &:disabled { + color: var(--gray); + cursor: default; + opacity: 0.5; + } +`; + +export const Title = styled.p` + font-family: ${(props) => props.theme.fonts}; + font-size: 16px; + font-weight: 800; + line-height: 22px; + color: currentcolor; + white-space: nowrap; + + @media ${devices.mobile} { + font-size: 10px; + } + + @media ${devices.tablet} { + font-size: 12px; + line-height: 16px; + } + + @media ${devices.desktop} { + font-size: 14px; + line-height: 20px; + } +`; diff --git a/src/components/product/ui/product-header/index.ts b/src/components/product/ui/product-header/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..61248202668960fa47d21b3fe676305d326e37a6 --- /dev/null +++ b/src/components/product/ui/product-header/index.ts @@ -0,0 +1 @@ +export * from './product-header'; diff --git a/src/components/product/ui/product-header/product-header.tsx b/src/components/product/ui/product-header/product-header.tsx new file mode 100644 index 0000000000000000000000000000000000000000..59d9bf0476511123688d50042c9f15110b2a3558 --- /dev/null +++ b/src/components/product/ui/product-header/product-header.tsx @@ -0,0 +1,30 @@ +import { FC } from 'react'; + +import { BackGroup, Icon } from '@/shared'; + +import * as Styled from './styled'; + +interface ProductHeaderProps { + name: string; + size: string; + handleBackClick: () => void; +} +export const ProductHeader: FC = ({ + name, + size, + handleBackClick, +}) => ( + + + + {size} + + + + + +); diff --git a/src/components/product/ui/product-header/styled.tsx b/src/components/product/ui/product-header/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c051112d36f3e793c1d088aac9471b8420270b75 --- /dev/null +++ b/src/components/product/ui/product-header/styled.tsx @@ -0,0 +1,28 @@ +import styled from 'styled-components'; + +export const ProductHeader = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + margin: 20px 20px 0; +`; + +export const LikeGroup = styled.div` + display: flex; + flex-direction: row; + gap: 20px; + align-items: center; +`; +export const SizeText = styled.p` + font-family: ${(props) => props.theme.fonts}; + font-size: 10px; + font-weight: 800; + line-height: 14px; + color: var(--black); + white-space: nowrap; +`; + +export const FavoriteButton = styled.button` + cursor: pointer; + background-color: transparent; +`; diff --git a/src/components/product/ui/product-image/index.ts b/src/components/product/ui/product-image/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..485544c4cd550ddb05b4407f7f7d68b3fb43daa0 --- /dev/null +++ b/src/components/product/ui/product-image/index.ts @@ -0,0 +1 @@ +export * from './product-image'; diff --git a/src/components/product/ui/product-image/product-image.tsx b/src/components/product/ui/product-image/product-image.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e2f7caf077b1221040617687e78bc3990352fdd1 --- /dev/null +++ b/src/components/product/ui/product-image/product-image.tsx @@ -0,0 +1,12 @@ +import { FC } from 'react'; + +import * as Styled from './styled'; + +interface ProductImageProps { + src: string; +} +export const ProductImage: FC = ({ src }) => ( + + + +); diff --git a/src/components/product/ui/product-image/styled.tsx b/src/components/product/ui/product-image/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7f0cb3f434372b543ff3a15de7f500e92702450c --- /dev/null +++ b/src/components/product/ui/product-image/styled.tsx @@ -0,0 +1,11 @@ +import styled from 'styled-components'; + +export const ImageContainer = styled.div` + margin: 0 auto; +`; + +export const Image = styled.img` + width: 100%; + height: auto; + object-fit: cover; +`; diff --git a/src/components/product/ui/product-info/index.ts b/src/components/product/ui/product-info/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..703cb5305c337d7eb302877124b4604b34f74d3f --- /dev/null +++ b/src/components/product/ui/product-info/index.ts @@ -0,0 +1 @@ +export * from './product-info'; diff --git a/src/components/product/ui/product-info/product-info.tsx b/src/components/product/ui/product-info/product-info.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7cd99197f7fddb0942a0ae2e29a1aecf0efff4d7 --- /dev/null +++ b/src/components/product/ui/product-info/product-info.tsx @@ -0,0 +1,41 @@ +import { FC } from 'react'; + +import { INutritionalValue, IProductInfo } from '@/interfaces'; + +import * as Styled from './styled'; + +interface ProductInfoProps { + info: IProductInfo | null; + nutritionalValue: INutritionalValue[] | null; +} +export const ProductInfo: FC = ({ + info, + nutritionalValue, +}) => ( + + {info?.desc} + + {nutritionalValue && ( + + + + {nutritionalValue?.map((value) => ( + + {value.name}, {value.unit} + + ))} + + + + + {nutritionalValue?.map((value) => ( + + {value.value} + + ))} + + + + )} + +); diff --git a/src/components/product/ui/product-info/styled.tsx b/src/components/product/ui/product-info/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3d15e787a17f63ce308beac0bf8690b931c4ca0a --- /dev/null +++ b/src/components/product/ui/product-info/styled.tsx @@ -0,0 +1,60 @@ +import styled from 'styled-components'; + +import { devices } from '@/styles'; + +export const ProductInfo = styled.div` + display: flex; + flex-direction: column; + gap: 32px; + align-items: center; +`; + +export const ProductDesription = styled.p` + width: 100%; + font-family: ${(props) => props.theme.fonts}; + font-size: 12px; + font-weight: 800; + line-height: 16px; + color: var(--black); + text-align: left; +`; + +export const Table = styled.table` + max-width: 350px; + margin: 0 -20px; + border-spacing: 20px 6px; + border-collapse: separate; +`; + +export const TableHead = styled.thead``; + +export const TableRow = styled.tr``; + +export const TableBody = styled.tbody``; + +export const TableHeader = styled.th` + font-family: ${(props) => props.theme.fonts}; + font-size: 12px; + font-weight: 800; + line-height: 16px; + color: var(--gray-plus); + text-align: left; + letter-spacing: 0; + white-space: nowrap; + + @media ${devices.mobile} { + font-size: 10px; + } +`; + +export const TableData = styled.td` + font-family: ${(props) => props.theme.fonts}; + font-size: 12px; + font-weight: 800; + line-height: 16px; + color: var(--black); + + @media ${devices.mobile} { + font-size: 10px; + } +`; diff --git a/src/components/product/ui/product-modifications/index.ts b/src/components/product/ui/product-modifications/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..3f606425b571a8fff9f9aa5f1b4372730649774d --- /dev/null +++ b/src/components/product/ui/product-modifications/index.ts @@ -0,0 +1 @@ +export * from './product-modifications'; diff --git a/src/components/product/ui/product-modifications/product-modifications.tsx b/src/components/product/ui/product-modifications/product-modifications.tsx new file mode 100644 index 0000000000000000000000000000000000000000..590aba9bd4af9e36f6280d1b21e14079806f92c0 --- /dev/null +++ b/src/components/product/ui/product-modifications/product-modifications.tsx @@ -0,0 +1,69 @@ +import { FC } from 'react'; + +import { IFormState, IModifications, Stylebook } from '@/interfaces'; +import { formatPrice, parseToInt } from '@/utils'; + +import * as Styled from './styled'; +import { ProductCheckbox } from '../product-checkbox'; +import { ProductRadio } from '../product-radio'; + +interface ProductModificationsProps { + modifications: IModifications | null; + stylebook: Stylebook | null; + formState: IFormState; + handleAddonChange: (value: string, price: string, isChecked: boolean) => void; + handleModificationChange: (value: string) => void; +} +export const ProductModifications: FC = ({ + modifications, + stylebook, + formState, + handleAddonChange, + handleModificationChange, +}) => ( + + + + Добавки + {modifications?.addons?.map((addon) => ( + + + {addon.price ? ( + + {formatPrice(parseToInt(addon.price))} + + ) : null} + + ))} + + + Модификации + {modifications?.options?.map((option) => ( + + + {option.price ? ( + + {formatPrice(parseToInt(option.price))} + + ) : null} + + ))} + + + +); diff --git a/src/components/product/ui/product-modifications/styled.tsx b/src/components/product/ui/product-modifications/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..33bfd9af41185ecda9298b69d1cef08b5d217fdc --- /dev/null +++ b/src/components/product/ui/product-modifications/styled.tsx @@ -0,0 +1,49 @@ +import styled from 'styled-components'; + +interface ProductModificationsProps { + $colorItem?: string; +} + +export const ProductModifications = styled.div` + display: flex; + flex-direction: column; +`; + +export const Form = styled.form` + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 32px; + width: 100%; +`; + +export const Fieldset = styled.fieldset` + display: flex; + flex-direction: column; + gap: 16px; + border: none; +`; + +export const Legend = styled.legend` + margin-bottom: 10px; + font-family: ${(props) => props.theme.fonts}; + font-size: 12px; + font-weight: 800; + line-height: 16px; + color: var(--gray); +`; + +export const RadioWrapper = styled.div` + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; +`; + +export const ModPrice = styled.span` + font-family: ${(props) => props.theme.fonts}; + font-size: 12px; + font-weight: 800; + line-height: 16px; + color: ${({ $colorItem }) => $colorItem || 'black'}; +`; diff --git a/src/components/product/ui/product-page-view/index.ts b/src/components/product/ui/product-page-view/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed96066f5491a1b497da0fc963bfea19bdbd2057 --- /dev/null +++ b/src/components/product/ui/product-page-view/index.ts @@ -0,0 +1 @@ +export * from './product-page-view'; diff --git a/src/components/product/ui/product-page-view/product-page-view.tsx b/src/components/product/ui/product-page-view/product-page-view.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b48362d0b216d9f395d0c1793fe193b95ad788d9 --- /dev/null +++ b/src/components/product/ui/product-page-view/product-page-view.tsx @@ -0,0 +1,135 @@ +import { FC, useEffect } from 'react'; + +import { + IProduct, + IProductRecommendations, + OperatingHours, + Stylebook, +} from '@/interfaces'; +import { + addAddon, + changeModification, + initializeProduct, + removeAddon, + updateCount, + addItem, + removeItem, + currentProductSelector, + basketSelector, +} from '@/store'; +import { updateOpenMode, isObjectsEqual } from '@/utils'; +import { useAppDispatch, useAppSelector } from '@/hooks'; + +import { ProductDetails } from '../product-details'; +import { ProductFooter } from '../product-footer'; +import { Recommendations } from '../recommendations'; +import * as Styled from './styled'; + +interface IProductPageViewProps { + mode: OperatingHours | null; + product: IProduct | null; + recommendations: IProductRecommendations[] | null; + stylebook: Stylebook | null; + shop: string | null; +} + +export const ProductPageView: FC = ({ + mode, + product, + recommendations, + stylebook, + shop, +}) => { + const isOpen = updateOpenMode(mode); + const basket = useAppSelector(basketSelector); + const currentProduct = useAppSelector(currentProductSelector); + const dispatch = useAppDispatch(); + + useEffect(() => { + if (!product || currentProduct.productSlug === product?.slug) { + return; + } + dispatch( + initializeProduct({ + icon: product.icon || 'cake', + name: product.name || '', + productSlug: product?.slug || '', + cost: Number(product?.price), + modification: product?.modifications?.options[0]?.name || '', + addons: [], + count: 0, + }), + ); + }, [product, dispatch, currentProduct.productSlug]); + + useEffect(() => { + if (!shop || !basket[shop]) { + return; + } + let countToUpdate = 0; + basket[shop].items.forEach((item) => { + if (isObjectsEqual(item, currentProduct)) countToUpdate = item.count; + }); + dispatch(updateCount(countToUpdate)); + }, [shop, dispatch, basket, currentProduct]); + + const handleAddonChange = ( + value: string, + price: string, + isChecked: boolean, + ) => { + if (isChecked) { + dispatch(addAddon({ value, price })); + } else { + dispatch(removeAddon({ value, price })); + } + }; + + const options = product?.modifications?.options; + const handleModificationChange = (value: string) => { + if (!options) return; + dispatch(changeModification({ value, options })); + }; + + const handleDecreaseCount = () => { + if (shop) dispatch(removeItem({ shop, item: currentProduct })); + }; + + const handleIncreaseCount = () => { + if (shop) dispatch(addItem({ shop, item: currentProduct })); + }; + + return ( + + + + + + + + ); +}; diff --git a/src/components/product/ui/product-page-view/styled.tsx b/src/components/product/ui/product-page-view/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..15e2dc283820b9a4a6a24f21a4b012511bfa5a3c --- /dev/null +++ b/src/components/product/ui/product-page-view/styled.tsx @@ -0,0 +1,39 @@ +import styled from 'styled-components'; + +import { Stylebook } from '@/interfaces'; + +interface ProductWrapperDetailsProps { + $stylebook: Stylebook | null; +} + +const patternUrl = (props: ProductWrapperDetailsProps) => + `/patterns/${props.$stylebook?.pattern}.png`; + +export const ProductContentWrapper = styled.div` + display: flex; + flex-direction: column; + background-color: var(--white); +`; + +export const ProductContent = styled.div` + position: absolute; + right: 0; + bottom: 41px; + left: 0; + display: flex; + flex-direction: column; + min-height: 58%; + padding-bottom: 41px; + background-color: ${(props) => props.$stylebook?.mainColor}; + border-top-left-radius: 15px; + border-top-right-radius: 15px; + + &::before { + position: absolute; + inset: 0; + pointer-events: none; + content: ''; + background-image: url(${patternUrl}); + opacity: ${(props) => props.$stylebook?.opacity}; + } +`; diff --git a/src/components/product/ui/product-radio/index.ts b/src/components/product/ui/product-radio/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..40d0ae0f29ade02e63791c343e6c0e1c90361c95 --- /dev/null +++ b/src/components/product/ui/product-radio/index.ts @@ -0,0 +1 @@ +export * from './product-radio'; diff --git a/src/components/product/ui/product-radio/product-radio.tsx b/src/components/product/ui/product-radio/product-radio.tsx new file mode 100644 index 0000000000000000000000000000000000000000..09d917ea61c469fe29bebe6b164d31a5358efa1c --- /dev/null +++ b/src/components/product/ui/product-radio/product-radio.tsx @@ -0,0 +1,35 @@ +import { FC, HTMLProps } from 'react'; + +import { Radio, RadioActive } from '@/shared'; + +import * as Styled from './styled'; + +interface RadioProps extends HTMLProps { + label: string; + value: string; + name: string; + activeValue: string; + handleModificationChange: (value: string) => void; + colorItem?: string; +} + +export const ProductRadio: FC = ({ + label, + value, + name, + activeValue, + handleModificationChange, + colorItem, +}) => ( + + {activeValue === value ? : } + handleModificationChange(value)} + /> + {label} + +); diff --git a/src/components/product/ui/product-radio/styled.tsx b/src/components/product/ui/product-radio/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..802973f9745effa4790f1556b3145471487e7154 --- /dev/null +++ b/src/components/product/ui/product-radio/styled.tsx @@ -0,0 +1,24 @@ +import styled from 'styled-components'; + +interface StyledRadioProps { + $colorItem?: string; +} + +export const StyledRadioLabel = styled.label` + display: inline-flex; + gap: 6px; + align-items: center; + font-size: 12px; + font-weight: 800; + line-height: 16px; + color: ${({ $colorItem }) => $colorItem || 'black'}; + cursor: pointer; +`; + +export const StyledRadio = styled.input` + display: none; +`; + +export const StyledSpan = styled.span` + color: var(--black-plus); +`; diff --git a/src/components/product/ui/recommendations/index.ts b/src/components/product/ui/recommendations/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa1cf72591c594064f5358e92c4d22fd78a6d5c0 --- /dev/null +++ b/src/components/product/ui/recommendations/index.ts @@ -0,0 +1 @@ +export * from './recommendations'; diff --git a/src/components/product/ui/recommendations/recommendations.tsx b/src/components/product/ui/recommendations/recommendations.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c3849c97d57fd546f38babbbbc9817a7d7b681e5 --- /dev/null +++ b/src/components/product/ui/recommendations/recommendations.tsx @@ -0,0 +1,37 @@ +import { FC } from 'react'; + +import { IProductRecommendations, Stylebook } from '@/interfaces'; + +import { ProductCardTopContent } from '../../../product-card-top-content'; +import { ProductCardBottomContent } from '../../../product-card-bottom-content'; +import { ProductCardLink } from '../../../product-card-link'; +import * as Styled from './styled'; + +interface IRecommendationsProps { + recommendations: IProductRecommendations[]; + shop?: string; + stylebook?: Stylebook | null; +} + +export const Recommendations: FC = ({ + recommendations, + shop, + stylebook, +}) => ( + + {recommendations.map((item) => ( + } + to={`/catalog/${shop}/product/${item.slug}`} + topContent={ + + } + /> + ))} + +); diff --git a/src/components/product/ui/recommendations/styled.tsx b/src/components/product/ui/recommendations/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c104773a20c9b4b4d4e51071938e1e0e7da440a6 --- /dev/null +++ b/src/components/product/ui/recommendations/styled.tsx @@ -0,0 +1,12 @@ +import ScrollContainer from 'react-indiana-drag-scroll'; +import styled from 'styled-components'; + +export const Recommendations = styled(ScrollContainer)` + z-index: 1; + display: flex; + flex-direction: row; + gap: 12px; + justify-content: flex-start; + padding-right: 20px; + padding-left: 20px; +`; diff --git a/src/components/profile/index.ts b/src/components/profile/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..671f42568b53ee817a625b7144c627223acd9d79 --- /dev/null +++ b/src/components/profile/index.ts @@ -0,0 +1 @@ +export * from './profile'; diff --git a/src/components/profile/profile.tsx b/src/components/profile/profile.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9acd17354630924454491efc1221c82fd0c5a1ab --- /dev/null +++ b/src/components/profile/profile.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; + +import { BackGroup } from '@/shared'; +import { useAppDispatch, useAppSelector } from '@/hooks'; +import { useLogout, useUser } from '@/api-hooks'; +import { loggedInSelector, setUser, userSelector } from '@/store'; + +import * as Styled from './styled'; +import { ProfileGroup, ProfileButton, ProfileForm } from './ui'; + +export const Profile = () => { + const dispatch = useAppDispatch(); + const loggedIn = useAppSelector(loggedInSelector); + const user = useAppSelector(userSelector); + const router = useRouter(); + const [isEditing, setIsEditing] = useState(false); + + const { data, refetch: refetchUser } = useUser(user.id?.toString() || ''); + if (loggedIn) refetchUser(); + useEffect(() => { + if (data?.success) { + dispatch(setUser({ data: data.user })); + } + }, [data, dispatch]); + + const handleBackClick = () => { + router.push('/'); + }; + + const handleLogout = useLogout(); + + const handleLoginClick = () => { + router.push('/login'); + }; + + const handleRegisterClick = () => { + router.push('/signup'); + }; + + const handleEdit = () => { + setIsEditing(true); + }; + + const handleCloseEdit = () => { + setIsEditing(false); + }; + + if (!loggedIn) { + return ( + + + + + + + + ); + } + + return ( + + + {isEditing ? ( + + ) : ( + + )} + + ); +}; diff --git a/src/components/profile/schema/index.ts b/src/components/profile/schema/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4860529d33beb13b42e7274c5ec32424ed9dac37 --- /dev/null +++ b/src/components/profile/schema/index.ts @@ -0,0 +1 @@ +export * from './schema-profile-form'; diff --git a/src/components/profile/schema/schema-profile-form.ts b/src/components/profile/schema/schema-profile-form.ts new file mode 100644 index 0000000000000000000000000000000000000000..f2afc5e56a991a5d2d3a4eb595cdac80b9d75470 --- /dev/null +++ b/src/components/profile/schema/schema-profile-form.ts @@ -0,0 +1,61 @@ +import { isValidPhoneNumber } from 'libphonenumber-js'; +import { ZodType, z } from 'zod'; + +export const ProfileSchema: ZodType = z + .object({ + avatar: z.string().optional(), + firstName: z + .string() + .optional() + .transform((value) => (value === '' ? undefined : value)), + lastName: z + .string() + .optional() + .transform((value) => (value === '' ? undefined : value)), + surname: z + .string() + .optional() + .transform((value) => (value === '' ? undefined : value)), + phone: z + .string() + .transform((val) => val.replace(/[\s()-]/g, '')) + .refine((val) => isValidPhoneNumber(val, 'RU'), { + message: 'Неверный номер телефона (RU).', + }), + email: z + .string() + .email({ message: 'Неверный адрес электронной почты.' }) + .refine( + (val) => /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(val), + { + message: 'Неверный адрес электронной почты.', + }, + ), + isIncognita: z.boolean(), + }) + .refine( + (data) => { + if (!data.isIncognita) { + return !!data.firstName; + } + return true; + }, + { + message: 'Имя обязательное поле.', + path: ['firstName'], + }, + ) + .refine( + (data) => { + if (!data.isIncognita) { + return !!data.lastName; + } + return true; + }, + { + message: 'Фамилия обязательное поле.', + path: ['lastName'], + }, + ); + +export type TProfileSchema = z.infer; diff --git a/src/components/profile/styled.tsx b/src/components/profile/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9aeb12553576ab2abdc12d72a9ddd06d50a3c543 --- /dev/null +++ b/src/components/profile/styled.tsx @@ -0,0 +1,30 @@ +import styled from 'styled-components'; + +import { devices } from '@/styles'; + +export const Profile = styled.div` + box-sizing: border-box; + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + padding: 20px; + background-color: var(--blue); +`; + +export const ProfileAuthButtons = styled.div` + display: flex; + flex-direction: column; + gap: 20px; + margin: auto 0; + + @media ${devices.tablet} { + margin-right: 40px; + margin-left: 40px; + } + + @media ${devices.mobile} { + margin-right: 0; + margin-left: 0; + } +`; diff --git a/src/components/profile/ui/index.ts b/src/components/profile/ui/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0951af12499b88b92b9152e3d08d7b2cf9fef594 --- /dev/null +++ b/src/components/profile/ui/index.ts @@ -0,0 +1,3 @@ +export * from './profile-group'; +export * from './profile-button'; +export * from './profile-form'; diff --git a/src/components/profile/ui/modal-avatar/index.ts b/src/components/profile/ui/modal-avatar/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d61a93757f0a71faea02d0d19edd62bb7fed1bf1 --- /dev/null +++ b/src/components/profile/ui/modal-avatar/index.ts @@ -0,0 +1 @@ +export * from './modal-avatar'; diff --git a/src/components/profile/ui/modal-avatar/modal-avatar.tsx b/src/components/profile/ui/modal-avatar/modal-avatar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..087f3d6fe69f5e6841767cd0d8454a2687219989 --- /dev/null +++ b/src/components/profile/ui/modal-avatar/modal-avatar.tsx @@ -0,0 +1,42 @@ +import { FC } from 'react'; +import { Control, Controller } from 'react-hook-form'; + +import { BackGroup } from '@/shared'; +import { avatars } from '@/utils'; + +import * as Styled from './styled'; + +interface IModalAvatar { + handleClose: () => void; + control: Control; +} +export const ModalAvatar: FC = ({ handleClose, control }) => ( + + + ( + + {avatars.map((avatar) => ( + { + onChange(avatar); + handleClose(); + }} + > + + + + + ))} + + )} + /> + +); diff --git a/src/components/profile/ui/modal-avatar/styled.tsx b/src/components/profile/ui/modal-avatar/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1433a3522213c785601c32cb54ca5a1f18b867fb --- /dev/null +++ b/src/components/profile/ui/modal-avatar/styled.tsx @@ -0,0 +1,63 @@ +import styled from 'styled-components'; + +import { devices } from '@/styles'; + +export const ModalAvatar = styled.div` + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: flex-start; + width: 100%; + height: 100%; + padding: 40px 20px 0; + background-color: var(--green); + + @media ${devices.tablet} { + padding-right: 40px; + padding-left: 40px; + } + + @media ${devices.mobile} { + padding-right: 20px; + padding-left: 20px; + } +`; + +export const AvatarsGroup = styled.div` + box-sizing: border-box; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(38px, 15%)); + grid-auto-rows: repeat(auto-fit, minmax(38px, auto)); + gap: 15px; + justify-content: center; + width: 100%; + height: 100%; + padding: 20px 20px 40px; + overflow-y: auto; + -ms-overflow-style: none; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +`; + +export const AvatarButton = styled.button` + box-sizing: border-box; + width: 100%; + height: 100%; + margin-bottom: 15px; + cursor: pointer; + filter: drop-shadow(2px 2px 2px rgb(0 0 0 / 50%)); + transition: 0.2s ease; + + &:hover { + filter: drop-shadow(3px 3px 8px rgb(0 0 0 / 80%)); + transform: translateY(-1px); + } + + &:active { + filter: drop-shadow(2px 2px 2px rgb(0 0 0 / 50%)); + transform: translateY(0); + } +`; diff --git a/src/components/profile/ui/phone-input/index.ts b/src/components/profile/ui/phone-input/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..426d04b9d39ce4189d4cf0d1f0708eb0f72345cc --- /dev/null +++ b/src/components/profile/ui/phone-input/index.ts @@ -0,0 +1 @@ +export * from './phone-input'; diff --git a/src/components/profile/ui/phone-input/phone-input.tsx b/src/components/profile/ui/phone-input/phone-input.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a021316a4069b491dc10ae246605d091faaf794b --- /dev/null +++ b/src/components/profile/ui/phone-input/phone-input.tsx @@ -0,0 +1,42 @@ +import { PatternFormat } from 'react-number-format'; +import { FC } from 'react'; +import { Control, Controller } from 'react-hook-form'; +import classNames from 'classnames'; + +import * as Styled from './styled'; +import styled from '../styles/input.module.css'; + +interface IPhoneInput { + control: Control; +} +export const PhoneInput: FC = ({ control }) => ( + + ( + + + {error?.message && ( + {error.message} + )} + + )} + /> + +); diff --git a/src/components/profile/ui/phone-input/styled.tsx b/src/components/profile/ui/phone-input/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..37c8316b455c04c283e47b90dc7ecd0c0206c7f2 --- /dev/null +++ b/src/components/profile/ui/phone-input/styled.tsx @@ -0,0 +1,38 @@ +import styled from 'styled-components'; + +export const PhoneInput = styled.div` + display: flex; + flex-direction: row; + gap: 10px; + align-items: center; +`; + +export const AuthInputWrapper = styled.div` + position: relative; + margin-bottom: 18px; +`; + +export const UserPhoneInput = styled.input` + width: 100%; + height: 40px; + font-family: ${(props) => props.theme.fonts}; + font-size: 14px; + font-weight: 800; + background-color: transparent; + border: none; +`; + +export const InputError = styled.span` + position: absolute; + top: 26px; + left: 0; + display: block; + width: 300px; + overflow: hidden; + font-size: 12px; + font-weight: 400; + line-height: 12px; + color: var(--red); + text-overflow: ellipsis; + white-space: nowrap; +`; diff --git a/src/components/profile/ui/profile-button/index.ts b/src/components/profile/ui/profile-button/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..9e17903ff6dc0b14d6364a25ea0cdcf43622fc43 --- /dev/null +++ b/src/components/profile/ui/profile-button/index.ts @@ -0,0 +1 @@ +export * from './profile-button'; diff --git a/src/components/profile/ui/profile-button/profile-button.tsx b/src/components/profile/ui/profile-button/profile-button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9902b58c22b6363812fb94fad581db605ba13201 --- /dev/null +++ b/src/components/profile/ui/profile-button/profile-button.tsx @@ -0,0 +1,17 @@ +import { FC, HTMLProps } from 'react'; + +import * as Styled from './styled'; + +interface IProfileButton extends HTMLProps { + title: string; + colorItem?: string; +} +export const ProfileButton: FC = ({ + title, + onClick, + colorItem, +}) => ( + + {title} + +); diff --git a/src/components/profile/ui/profile-button/styled.tsx b/src/components/profile/ui/profile-button/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fcb526a0ca4026be001a610eda6fd2e91c88f11a --- /dev/null +++ b/src/components/profile/ui/profile-button/styled.tsx @@ -0,0 +1,18 @@ +import styled from 'styled-components'; + +export const Button = styled.button<{ $colorItem?: string }>` + width: 100%; + padding: 16px 0 14px; + font-size: 14px; + font-weight: 800; + line-height: 20px; + color: ${(props) => props.$colorItem || 'var(--black)'}; + text-align: center; + letter-spacing: 0; + cursor: pointer; + user-select: none; + background-color: var(--white); + border: 2px solid var(--black-plus); + border-radius: 10px; + box-shadow: 2px 2px 2px rgb(0 0 0 / 25%); +`; diff --git a/src/components/profile/ui/profile-form/index.ts b/src/components/profile/ui/profile-form/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f21606f1b5e7ff17ef771c9083600771be0a862a --- /dev/null +++ b/src/components/profile/ui/profile-form/index.ts @@ -0,0 +1 @@ +export * from './profile-form'; diff --git a/src/components/profile/ui/profile-form/profile-form.tsx b/src/components/profile/ui/profile-form/profile-form.tsx new file mode 100644 index 0000000000000000000000000000000000000000..60fe7a1d83d24c0b062c59eba743c61f09a954c3 --- /dev/null +++ b/src/components/profile/ui/profile-form/profile-form.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { FC, useEffect, useState } from 'react'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { AnimatePresence } from 'framer-motion'; + +import { + ActionAuthButton, + AuthCheckbox, + AuthInput, + LayoutModal, + Portal, +} from '@/shared'; +import { useAppDispatch, useAppSelector } from '@/hooks'; +import { useUserMutation } from '@/api-hooks'; +import { setUser, userSelector } from '@/store'; + +import * as Styled from './styled'; +import { PhoneInput } from '../phone-input'; +import { ProfileSchema, TProfileSchema } from '../../schema'; +import { ModalAvatar } from '../modal-avatar'; + +interface IProfileForm { + handleCloseEdit: () => void; +} +export const ProfileForm: FC = ({ handleCloseEdit }) => { + const [isModal, setIsModal] = useState(false); + const handleCloseModal = () => setIsModal(false); + const handleAvatarClick = () => setIsModal(true); + const user = useAppSelector(userSelector); + const dispatch = useAppDispatch(); + + const { mutate, data, isPending } = useUserMutation( + user.id?.toString() || '', + ); + + useEffect(() => { + if (data?.success) { + dispatch(setUser({ data: data.user })); + handleCloseEdit(); + } + }, [data, dispatch, handleCloseEdit]); + + const { + watch, + control, + handleSubmit, + formState: { isValid, isDirty }, + } = useForm({ + resolver: zodResolver(ProfileSchema), + defaultValues: { + avatar: user.avatar, + firstName: user.firstName, + lastName: user.lastName, + surname: user.surname, + phone: user.phone, + email: user.email, + isIncognita: JSON.parse(user.isIncognita || 'false'), + }, + mode: 'onTouched', + }); + const isIncognitaValue = watch('isIncognita'); + + const selectedAvatar = watch('avatar'); + const onSubmit: SubmitHandler = (dataForm) => { + mutate(dataForm); + }; + + return ( + + + + + + + + + + + + + + + + + + + + + {isModal && ( + + + + + + )} + + + ); +}; diff --git a/src/components/profile/ui/profile-form/styled.tsx b/src/components/profile/ui/profile-form/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5c7e1e1055165062cae30407bc09f12c6f7ac2dc --- /dev/null +++ b/src/components/profile/ui/profile-form/styled.tsx @@ -0,0 +1,75 @@ +import styled from 'styled-components'; + +import { devices } from '@/styles'; + +export const ProfileForm = styled.form` + position: relative; + box-sizing: border-box; + display: grid; + flex-direction: column; + grid-template-columns: 1fr; + grid-auto-rows: auto; + column-gap: 32px; + padding: 20px 20px 30px; + margin-top: 32px; + background-color: var(--white); + border-radius: 15px; + + & :first-child { + grid-column: 1 / -1; + } + + @media ${devices.tablet} { + grid-template-columns: 1fr 1fr; + padding-right: 40px; + padding-left: 40px; + margin-right: 60px; + margin-left: 60px; + } + + @media ${devices.mobile} { + grid-template-columns: 1fr; + padding-right: 20px; + padding-left: 20px; + margin-right: 0; + margin-left: 0; + } +`; + +export const Fieldset = styled.fieldset``; + +export const ButtonWrapper = styled.div` + position: absolute; + right: 22px; + bottom: -22px; + box-shadow: 0 4px 4px rgb(0 0 0 / 25%); +`; + +export const Avatar = styled.button` + width: 48px; + height: 48px; + margin-bottom: 20px; + cursor: pointer; + filter: drop-shadow(2px 2px 2px rgb(0 0 0 / 50%)); + transition: 0.2s ease; + + &:hover { + filter: drop-shadow(3px 3px 8px rgb(0 0 0 / 80%)); + transform: translateY(-1px); + } + + &:active { + filter: drop-shadow(2px 2px 2px rgb(0 0 0 / 50%)); + transform: translateY(0); + } + + @media ${devices.tablet} { + width: 78px; + height: 78px; + } + + @media ${devices.mobile} { + width: 48px; + height: 48px; + } +`; diff --git a/src/components/profile/ui/profile-group/index.ts b/src/components/profile/ui/profile-group/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e0cfe69ef3e3eea88bbece6c70e18331d7d6168d --- /dev/null +++ b/src/components/profile/ui/profile-group/index.ts @@ -0,0 +1 @@ +export * from './profile-group'; diff --git a/src/components/profile/ui/profile-group/profile-group.tsx b/src/components/profile/ui/profile-group/profile-group.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d30fd7361f4e5a15a129752a84b08643b4dbd226 --- /dev/null +++ b/src/components/profile/ui/profile-group/profile-group.tsx @@ -0,0 +1,48 @@ +import { FC } from 'react'; +import { PatternFormat } from 'react-number-format'; + +import { ActionButton } from '@/shared'; + +import * as Styled from './styled'; +import { ProfileButton } from '../profile-button'; + +interface IProfileGroup { + handleLogout: () => void; + handleEdit: () => void; + name: string; + phone: string; + avatar: string; +} + +export const ProfileGroup: FC = ({ + handleEdit, + handleLogout, + name, + phone, + avatar, +}) => ( + + + + + + + + + {name} + + + {value}} + value={phone} + /> + + + +); diff --git a/src/components/profile/ui/profile-group/styled.tsx b/src/components/profile/ui/profile-group/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b9316b4fa58d69459eda81c8596d46e90a0a4b1c --- /dev/null +++ b/src/components/profile/ui/profile-group/styled.tsx @@ -0,0 +1,81 @@ +import styled from 'styled-components'; + +import { devices } from '@/styles'; + +export const ProfileGroup = styled.div` + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 40px; + height: 100%; + margin-top: 32px; +`; + +export const ProfileContent = styled.div` + box-sizing: border-box; + display: flex; + flex-direction: column; + padding: 20px; + background-color: var(--white); + border-radius: 15px; + + @media ${devices.tablet} { + margin-right: 60px; + margin-left: 60px; + } + + @media ${devices.mobile} { + margin-right: 0; + margin-left: 0; + } +`; + +export const AvatarWrapper = styled.div` + width: 48px; + height: 48px; +`; + +export const UserEditInfo = styled.div` + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + margin-top: 15px; +`; + +export const UserName = styled.p` + font-size: 16px; + font-weight: 800; + line-height: 22px; + color: var(--black-plus); + text-align: left; + + @media ${devices.tablet} { + font-size: 20px; + line-height: 28px; + } + + @media ${devices.mobile} { + font-size: 16px; + line-height: 22px; + } +`; + +export const UserPhone = styled.p` + margin-top: 6px; + font-size: 13px; + font-weight: 800; + line-height: 16px; + color: var(--yellow); + text-align: left; + + @media ${devices.tablet} { + font-size: 18px; + line-height: 26px; + } + + @media ${devices.mobile} { + font-size: 13px; + line-height: 16px; + } +`; diff --git a/src/components/profile/ui/styles/input.module.css b/src/components/profile/ui/styles/input.module.css new file mode 100644 index 0000000000000000000000000000000000000000..f91550c03d5b7c20f916c755b5cc7415d627cec4 --- /dev/null +++ b/src/components/profile/ui/styles/input.module.css @@ -0,0 +1,22 @@ +.input { + color: var(--black-plus); + border-bottom: 2px solid var(--black-plus); + font-size: 14px; + font-weight: 800; + line-height: 20px; + padding-bottom: 2px; + width: 100%; + background-color: transparent; + + &:focus { + outline: none; + } + + &::placeholder { + color: var(--gray-plus); + } +} + +.inputError { + border-bottom: 2px solid var(--red); +} diff --git a/src/components/register/index.ts b/src/components/register/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..412f070006d034c80b8cd1a309c7b0a6d2035eaa --- /dev/null +++ b/src/components/register/index.ts @@ -0,0 +1 @@ +export * from './register'; diff --git a/src/components/register/register.tsx b/src/components/register/register.tsx new file mode 100644 index 0000000000000000000000000000000000000000..dfa58d68696273b8e7c8b40b7e810566e38a0867 --- /dev/null +++ b/src/components/register/register.tsx @@ -0,0 +1,159 @@ +'use client'; + +import { useState } from 'react'; +import { SubmitHandler, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { AnimatePresence } from 'framer-motion'; +import { isAxiosError } from 'axios'; +import { useRouter } from 'next/navigation'; + +import { ActionAuthButton, BackGroup, LayoutModal, Portal } from '@/shared'; +import { AUTH_TITLES } from '@/utils'; +import { useRegisterMutation } from '@/api-hooks'; +import { IGetUserResponse } from '@/interfaces'; + +import * as Styled from './styled'; +import { RegisterSchema, TRegisterSchema } from './schema'; +import { Steps, AuthErrorModal } from './ui'; + +export type TStepName = 1 | 2 | 3 | 4; + +export const Register = () => { + const router = useRouter(); + const handleBackClick = () => { + router.push('/'); + }; + const [step, setStep] = useState(1); + const [isModalError, setIsModalError] = useState(false); + const handleCloseModal = () => setIsModalError(false); + + const handleStepBack = () => + setStep((prev) => { + const count = prev > 1 ? prev - 1 : prev; + return count as TStepName; + }); + + const { + control, + watch, + handleSubmit, + formState: { isSubmitting }, + trigger, + } = useForm({ + resolver: zodResolver(RegisterSchema), + defaultValues: { + step1: { + firstName: '', + lastName: '', + surname: '', + isIncognita: false, + }, + step2: { + birthday: '', + }, + step3: { + typeCard: '', + numberCard: '', + codeCard: '', + }, + step4: { + phone: '', + email: '', + }, + }, + mode: 'onTouched', + }); + + const isIncognitaValue = watch('step1.isIncognita'); + const birthdayValue = watch('step2.birthday'); + const CardTypeValue = watch('step3.typeCard'); + + const handleSuccess = (data: IGetUserResponse) => { + if (data.success) router.replace('/login'); + }; + + const handleError = (error: Error) => { + if (isAxiosError(error) && error.code === '404') router.replace('/404'); + if (isAxiosError(error) && error.code === '500') + router.replace('/server-error'); + setIsModalError(true); + }; + + const { mutate } = useRegisterMutation(handleError, handleSuccess); + + const handleNext = async () => { + const isValid = await trigger(`step${step}`); + if (isValid) { + setStep((prev) => { + const count = prev < 4 ? prev + 1 : prev; + return count as TStepName; + }); + } + }; + + const onSubmit: SubmitHandler = (dataForm) => { + mutate({ + ...dataForm.step1, + ...dataForm.step2, + ...dataForm.step3, + ...dataForm.step4, + }); + }; + + return ( + + + + + + {AUTH_TITLES[step]} + + + + {step !== 1 ? ( + + + + ) : null} + + + {step < 4 ? ( + + ) : ( + + )} + + + + + {isModalError && ( + + + + + + )} + + + ); +}; diff --git a/src/components/register/schema/index.ts b/src/components/register/schema/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4bf811bf24e0f5faf01396965ab62879ad7044ec --- /dev/null +++ b/src/components/register/schema/index.ts @@ -0,0 +1 @@ +export * from './schema-register-form'; diff --git a/src/components/register/schema/schema-register-form.ts b/src/components/register/schema/schema-register-form.ts new file mode 100644 index 0000000000000000000000000000000000000000..1f8bab3d19f41695c73b7fd6f014b236bbb9c5ff --- /dev/null +++ b/src/components/register/schema/schema-register-form.ts @@ -0,0 +1,95 @@ +import { isValidPhoneNumber } from 'libphonenumber-js'; +import { ZodType, z } from 'zod'; + +const step1Schema = z + .object({ + firstName: z.string().optional(), + lastName: z.string().optional(), + surname: z.string().optional(), + isIncognita: z.boolean(), + }) + .refine( + (data) => { + if (!data.isIncognita) { + return !!data.firstName; + } + return true; + }, + { + message: 'Имя обязательное поле.', + path: ['firstName'], + }, + ) + .refine( + (data) => { + if (!data.isIncognita) { + return !!data.lastName; + } + return true; + }, + { + message: 'Фамилия обязательное поле.', + path: ['lastName'], + }, + ); + +const step2Schema = z.object({ + birthday: z.string().refine((val) => !Number.isNaN(Date.parse(val)), { + message: 'Необходимо заполнить дату рождения.', + }), +}); + +const step3Schema = z + .object({ + typeCard: z.string().transform((val) => val.toUpperCase()), + numberCard: z + .string() + .refine((val) => val.replace(/\D/g, '').length >= 16, { + message: 'Номер карты должен содержать минимум 16 цифр.', + }), + codeCard: z + .string() + .optional() + .refine((val) => (val ? val.replace(/\D/g, '').length === 3 : true), { + message: 'CVC должен содержать 3 цифры.', + }), + }) + .refine( + (data) => { + if (data.typeCard === 'PLASTIC') { + return !!data.codeCard; + } + return true; + }, + { + message: 'CVC обязательное поле.', + path: ['codeCard'], + }, + ); + +const step4Schema = z.object({ + phone: z + .string() + .transform((val) => val.replace(/[\s()-]/g, '')) + .refine((val) => isValidPhoneNumber(val, 'RU'), { + message: 'Неверный номер телефона (RU).', + }), + email: z + .string() + .email({ message: 'Неверный адрес электронной почты.' }) + .refine( + (val) => /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/.test(val), + { + message: 'Неверный адрес электронной почты.', + }, + ), +}); + +export const RegisterSchema: ZodType = z.object({ + step1: step1Schema, + step2: step2Schema, + step3: step3Schema, + step4: step4Schema, +}); + +export type TRegisterSchema = z.infer; diff --git a/src/components/register/styled.tsx b/src/components/register/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1de29754396f1a00f7947e8d29d241faf0257f2e --- /dev/null +++ b/src/components/register/styled.tsx @@ -0,0 +1,75 @@ +import styled from 'styled-components'; + +import { devices } from '@/styles'; + +export const Register = styled.div` + box-sizing: border-box; + width: 100%; + height: 100%; + padding: 20px; + background-color: var(--violet); +`; + +export const FormWrapper = styled.div` + box-sizing: border-box; + display: flex; + align-items: flex-start; + width: 100%; + height: 100%; + padding: 64px 28px 0; + background-color: var(--violet); + + @media ${devices.tablet} { + justify-content: center; + padding: 0; + padding-top: 20vh; + } +`; + +export const RegisterForm = styled.form` + position: relative; + display: flex; + flex-direction: column; + gap: 7px; + align-items: center; + width: 100%; + max-width: 328px; + padding: 30px; + background-color: var(--white); + border: 2px solid var(--black-plus); + border-radius: 15px; + box-shadow: 0 4px 4px rgb(0 0 0 / 25%); +`; + +export const Title = styled.p` + align-self: flex-start; + font-family: ${(props) => props.theme.fonts}; + font-size: 14px; + font-weight: 800; + line-height: 20px; + color: var(--black-plus); + + @media ${devices.tablet} { + font-size: 16px; + line-height: 22px; + } + + @media ${devices.mobile} { + font-size: 14px; + line-height: 20px; + } +`; + +export const BackButtonWrapper = styled.div` + position: absolute; + bottom: -22px; + left: 22px; + box-shadow: 0 4px 4px rgb(0 0 0 / 25%); +`; + +export const GoButtonWrapper = styled.div` + position: absolute; + right: 22px; + bottom: -22px; + box-shadow: 0 4px 4px rgb(0 0 0 / 25%); +`; diff --git a/src/components/register/ui/auth-error-modal/auth-error-modal.tsx b/src/components/register/ui/auth-error-modal/auth-error-modal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..48e4f904f95f7634544a2798e5caa5584a306a33 --- /dev/null +++ b/src/components/register/ui/auth-error-modal/auth-error-modal.tsx @@ -0,0 +1,27 @@ +import { Modal } from '@/shared'; + +import { ModalContent } from '../modal-content'; + +export const AuthErrorModal = ({ + handleClose, +}: { + handleClose: () => void; +}) => { + const content = ( + + ); + + return ( + + ); +}; diff --git a/src/components/register/ui/auth-error-modal/index.ts b/src/components/register/ui/auth-error-modal/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..a5227d938977c16fad848f8cf269587214320296 --- /dev/null +++ b/src/components/register/ui/auth-error-modal/index.ts @@ -0,0 +1 @@ +export * from './auth-error-modal'; diff --git a/src/components/register/ui/birth-date-step/birth-date-step.tsx b/src/components/register/ui/birth-date-step/birth-date-step.tsx new file mode 100644 index 0000000000000000000000000000000000000000..27cac548fd1215d240aa9c950714be70313973a4 --- /dev/null +++ b/src/components/register/ui/birth-date-step/birth-date-step.tsx @@ -0,0 +1,76 @@ +import { FC, forwardRef } from 'react'; +import { Control, Controller } from 'react-hook-form'; +import ReactDatePicker from 'react-datepicker'; +import 'react-datepicker/dist/react-datepicker.css'; + +import * as Styled from './styled'; + +interface IBirthDateStep { + control: Control; + birthdayValue: Date; +} + +interface CustomInputProps { + value?: string; + onClick?: () => void; +} + +const ExampleCustomInput = forwardRef( + ({ value, onClick }, ref) => ( + + + + + + + + + ), +); + +ExampleCustomInput.displayName = 'ExampleCustomInput'; + +export const BirthDateStep: FC = ({ + control, + birthdayValue, +}) => ( + + ( + <> + } + dateFormat="dd.MM.yyyy" + dropdownMode="select" + name={name} + selected={birthdayValue} + onBlur={onBlur} + onChange={(date) => onChange(date?.toISOString())} + /> + {error?.message && ( + {error.message} + )} + + )} + /> + +); diff --git a/src/components/register/ui/birth-date-step/index.ts b/src/components/register/ui/birth-date-step/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c7a2adda3a2d6ac3f52c92954f7a1f4ce90854e4 --- /dev/null +++ b/src/components/register/ui/birth-date-step/index.ts @@ -0,0 +1 @@ +export * from './birth-date-step'; diff --git a/src/components/register/ui/birth-date-step/styled.tsx b/src/components/register/ui/birth-date-step/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4c9e6412c78ba25e3ccec00ff4b61600b59386f6 --- /dev/null +++ b/src/components/register/ui/birth-date-step/styled.tsx @@ -0,0 +1,48 @@ +import styled from 'styled-components'; + +export const BirthDate = styled.div` + position: relative; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 7px; + width: 100%; +`; + +export const BirthDateInputWrapper = styled.div` + display: flex; + flex-direction: row; + margin-bottom: 28px; + border-bottom: 2px solid var(--black-plus); +`; + +export const BirthDateInput = styled.input` + width: 100%; + padding-bottom: 2px; + font-size: 14px; + font-weight: 800; + line-height: 20px; + color: var(--black-plus); + + &:focus { + border: none; + outline: none; + } +`; + +export const DateIcon = styled.div``; + +export const InputError = styled.span` + position: absolute; + top: 32px; + left: 0; + display: block; + width: 300px; + overflow: hidden; + font-size: 12px; + font-weight: 400; + line-height: 12px; + color: var(--red); + text-overflow: ellipsis; + white-space: nowrap; +`; diff --git a/src/components/register/ui/contact-info-step/contact-info-step.tsx b/src/components/register/ui/contact-info-step/contact-info-step.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2049343bc6c60750d9e145bbe8c5b8122dd0e4d0 --- /dev/null +++ b/src/components/register/ui/contact-info-step/contact-info-step.tsx @@ -0,0 +1,55 @@ +import { Control, Controller } from 'react-hook-form'; +import { FC } from 'react'; +import { PatternFormat } from 'react-number-format'; +import classNames from 'classnames'; + +import { AuthInput } from '@/shared'; + +import * as Styled from './styled'; +import styled from '../styles/input.module.css'; + +interface IContactInfoStep { + control: Control; + className?: string; +} + +export const ContactInfoStep: FC = ({ + control, + className, +}) => ( + + ( + + + {error?.message && ( + {error.message} + )} + + )} + /> + + + +); diff --git a/src/components/register/ui/contact-info-step/index.ts b/src/components/register/ui/contact-info-step/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..19a650fd2492e917a89c16cc9152c34e14158413 --- /dev/null +++ b/src/components/register/ui/contact-info-step/index.ts @@ -0,0 +1 @@ +export * from './contact-info-step'; diff --git a/src/components/register/ui/contact-info-step/styled.tsx b/src/components/register/ui/contact-info-step/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..801ecb807cc67bcb7d16f28ee66222f8225e57a1 --- /dev/null +++ b/src/components/register/ui/contact-info-step/styled.tsx @@ -0,0 +1,38 @@ +import styled from 'styled-components'; + +export const ContactInfo = styled.div` + display: flex; + flex-direction: column; + gap: 20px; + width: 100%; + margin-bottom: 28px; +`; + +export const AuthInputWrapper = styled.div` + position: relative; +`; + +export const Label = styled.label` + position: absolute; + display: flex; + font-family: ${(props) => props.theme.fonts}; + font-size: 12px; + font-weight: 800; + line-height: 16px; + color: var(--gray-plus); +`; + +export const InputError = styled.span` + position: absolute; + top: 26px; + left: 0; + display: block; + width: 300px; + overflow: hidden; + font-size: 12px; + font-weight: 400; + line-height: 12px; + color: var(--red); + text-overflow: ellipsis; + white-space: nowrap; +`; diff --git a/src/components/register/ui/index.ts b/src/components/register/ui/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..a3965419513a90f2d2f42f4dc2cbc302d714ac2f --- /dev/null +++ b/src/components/register/ui/index.ts @@ -0,0 +1,6 @@ +export * from './personal-info-step'; +export * from './birth-date-step'; +export * from './payment-info-step'; +export * from './contact-info-step'; +export * from './steps'; +export * from './auth-error-modal'; diff --git a/src/components/register/ui/modal-content/index.ts b/src/components/register/ui/modal-content/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..2c211af88f75c306576180c974b303bcd55d0458 --- /dev/null +++ b/src/components/register/ui/modal-content/index.ts @@ -0,0 +1 @@ +export * from './modal-content'; diff --git a/src/components/register/ui/modal-content/modal-content.tsx b/src/components/register/ui/modal-content/modal-content.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f1145c8cdcfc6718e943ca71e835c0affefcb15e --- /dev/null +++ b/src/components/register/ui/modal-content/modal-content.tsx @@ -0,0 +1,25 @@ +import { FC } from 'react'; + +import * as Styled from './styled'; + +interface IModalContent { + handleClose: () => void; + btnColor: string; + text: string; + subtitle: string; +} + +export const ModalContent: FC = ({ + handleClose, + btnColor, + text, + subtitle, +}) => ( + <> + {text} + {subtitle} + + Ok + + +); diff --git a/src/components/register/ui/modal-content/styled.tsx b/src/components/register/ui/modal-content/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1bf99004b175f240efe31fcecfd48fc77b5fa8b5 --- /dev/null +++ b/src/components/register/ui/modal-content/styled.tsx @@ -0,0 +1,70 @@ +import styled from 'styled-components'; + +import { devices } from '@/styles'; + +export const MessageText = styled.p` + font-family: ${(props) => props.theme.fonts}; + font-size: 18px; + font-weight: 800; + line-height: 26px; + color: var(--black-plus); + + @media ${devices.tablet} { + font-size: 20px; + line-height: 28px; + } + + @media ${devices.mobile} { + font-size: 18px; + line-height: 26px; + } +`; + +export const MessageSubtitle = styled.p` + font-family: ${(props) => props.theme.fonts}; + font-size: 13px; + font-weight: 800; + line-height: 16px; + color: var(--gray); + + @media ${devices.tablet} { + font-size: 16px; + line-height: 22px; + } + + @media ${devices.mobile} { + font-size: 13px; + line-height: 16px; + } +`; + +export const PaymentCheckButton = styled.button<{ $btnColor: string }>` + position: absolute; + right: 22px; + bottom: -20px; + display: inline-block; + padding: 12px 30px; + font-size: 14px; + font-weight: 800; + line-height: 20px; + color: var(--white); + text-align: center; + letter-spacing: 0; + cursor: pointer; + user-select: none; + background-color: var(${({ $btnColor }) => $btnColor}); + border: 2px solid var(--black-plus); + border-radius: 5px; + box-shadow: 2px 2px 2px rgb(0 0 0 / 25%); + transition: 0.2s ease; + + &:hover { + box-shadow: 3px 3px 8px rgb(0 0 0 / 30%); + transform: translateY(-1px); + } + + &:active { + box-shadow: initial; + transform: translateY(0); + } +`; diff --git a/src/components/register/ui/payment-info-step/index.ts b/src/components/register/ui/payment-info-step/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..000ee2b6e92fe8440d683ba1cfa26456ddc93442 --- /dev/null +++ b/src/components/register/ui/payment-info-step/index.ts @@ -0,0 +1 @@ +export * from './payment-info-step'; diff --git a/src/components/register/ui/payment-info-step/payment-info-step.tsx b/src/components/register/ui/payment-info-step/payment-info-step.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1f022c39dc3fc11ca3dc195877280ccc704ba2e1 --- /dev/null +++ b/src/components/register/ui/payment-info-step/payment-info-step.tsx @@ -0,0 +1,93 @@ +import { Control, Controller } from 'react-hook-form'; +import { FC } from 'react'; +import { PatternFormat } from 'react-number-format'; +import classNames from 'classnames'; + +import { CustomSelect } from '@/shared'; + +import * as Styled from './styled'; +import styled from '../styles/input.module.css'; + +interface IPaymentInfoStep { + control: Control; + CardTypeValue: string; +} + +const paymentOptions = [ + { value: 'PLASTIC', label: 'Пластиковая' }, + { value: 'VIRTUAL', label: 'Виртуальная' }, +]; + +export const PaymentInfoStep: FC = ({ + control, + CardTypeValue, +}) => ( + + + + {CardTypeValue && ( + ( + + + {error?.message && ( + {error.message} + )} + + )} + /> + )} + + {CardTypeValue === 'PLASTIC' && ( + ( + + + {error?.message && ( + {error.message} + )} + + )} + /> + )} + +); diff --git a/src/components/register/ui/payment-info-step/styled.tsx b/src/components/register/ui/payment-info-step/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..708ab2941644f2a662bc5505eb647eca889f73e7 --- /dev/null +++ b/src/components/register/ui/payment-info-step/styled.tsx @@ -0,0 +1,44 @@ +import styled from 'styled-components'; + +export const PaymentInfo = styled.div` + display: flex; + flex-direction: column; + gap: 20px; + width: 100%; + margin-top: 13px; + margin-bottom: 28px; +`; + +export const CardCodeWrapper = styled.div` + position: relative; + width: 47px; +`; + +export const AuthInputWrapper = styled.div` + position: relative; +`; + +export const Label = styled.label` + position: absolute; + display: flex; + font-family: ${(props) => props.theme.fonts}; + font-size: 12px; + font-weight: 800; + line-height: 16px; + color: var(--gray-plus); +`; + +export const InputError = styled.span` + position: absolute; + top: 26px; + left: 0; + display: block; + width: 300px; + overflow: hidden; + font-size: 12px; + font-weight: 400; + line-height: 12px; + color: var(--red); + text-overflow: ellipsis; + white-space: nowrap; +`; diff --git a/src/components/register/ui/personal-info-step/index.ts b/src/components/register/ui/personal-info-step/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4c7ce0f07586e16c47dbe091fff70e2d73ef5d04 --- /dev/null +++ b/src/components/register/ui/personal-info-step/index.ts @@ -0,0 +1 @@ +export * from './personal-info-step'; diff --git a/src/components/register/ui/personal-info-step/personal-info-step.tsx b/src/components/register/ui/personal-info-step/personal-info-step.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f87109a14db777b4216f47ab10d5aab49b449c12 --- /dev/null +++ b/src/components/register/ui/personal-info-step/personal-info-step.tsx @@ -0,0 +1,45 @@ +import { Control } from 'react-hook-form'; +import { FC } from 'react'; + +import { AuthCheckbox, AuthInput } from '@/shared'; + +import * as Styled from './styled'; + +interface IPersonalInfoStep { + control: Control; + isIncognitaValue: boolean; +} + +export const PersonalInfoStep: FC = ({ + control, + isIncognitaValue, +}) => ( + + + + + + + *при наличии + + + + +); diff --git a/src/components/register/ui/personal-info-step/styled.tsx b/src/components/register/ui/personal-info-step/styled.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d7a0320bc2792d2e4c5af618c38df784ccb175b2 --- /dev/null +++ b/src/components/register/ui/personal-info-step/styled.tsx @@ -0,0 +1,52 @@ +import styled from 'styled-components'; + +import { devices } from '@/styles'; + +export const PersonalInfo = styled.div` + display: flex; + flex-direction: column; + gap: 7px; + width: 100%; +`; + +export const Fieldset = styled.fieldset` + display: flex; + flex-direction: column; +`; + +export const NameInputGroup = styled.div``; + +export const SurnameInputGroup = styled.div` + position: relative; +`; + +export const IncognitoButton = styled.button` + margin-top: 2px; + font-size: 14px; + line-height: 20px; + color: var(--green); +`; + +export const Info = styled.p` + position: absolute; + top: 26px; + font-size: 9px; + line-height: 12px; + color: var(--grey); + + @media ${devices.tablet} { + font-size: 11px; + line-height: 16px; + } + + @media ${devices.mobile} { + font-size: 9px; + line-height: 12px; + } +`; + +export const GoButtonWrapper = styled.div` + position: absolute; + right: 13px; + bottom: -19px; +`; diff --git a/src/components/register/ui/steps/index.ts b/src/components/register/ui/steps/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..9487d6700a75d14a863778cce7b7af6fc45b5928 --- /dev/null +++ b/src/components/register/ui/steps/index.ts @@ -0,0 +1 @@ +export * from './steps'; diff --git a/src/components/register/ui/steps/steps.tsx b/src/components/register/ui/steps/steps.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a8d622fd266874b16a7e53eefe62d2b5e2513156 --- /dev/null +++ b/src/components/register/ui/steps/steps.tsx @@ -0,0 +1,36 @@ +import { Control } from 'react-hook-form'; +import { FC } from 'react'; + +import { BirthDateStep } from '../birth-date-step'; +import { ContactInfoStep } from '../contact-info-step'; +import { PaymentInfoStep } from '../payment-info-step'; +import { PersonalInfoStep } from '../personal-info-step'; + +type TStepName = 1 | 2 | 3 | 4; + +interface ISteps { + step: TStepName; + control: Control; + CardTypeValue: string; + isIncognitaValue: boolean; + birthdayValue: Date; +} + +export const Steps: FC = ({ + step, + control, + CardTypeValue, + isIncognitaValue, + birthdayValue, +}) => { + const stepsComponents = { + 1: ( + + ), + 2: , + 3: , + 4: , + }; + + return stepsComponents[step]; +}; diff --git a/src/components/register/ui/styles/input.module.css b/src/components/register/ui/styles/input.module.css new file mode 100644 index 0000000000000000000000000000000000000000..f91550c03d5b7c20f916c755b5cc7415d627cec4 --- /dev/null +++ b/src/components/register/ui/styles/input.module.css @@ -0,0 +1,22 @@ +.input { + color: var(--black-plus); + border-bottom: 2px solid var(--black-plus); + font-size: 14px; + font-weight: 800; + line-height: 20px; + padding-bottom: 2px; + width: 100%; + background-color: transparent; + + &:focus { + outline: none; + } + + &::placeholder { + color: var(--gray-plus); + } +} + +.inputError { + border-bottom: 2px solid var(--red); +} diff --git a/src/components/shop-list/ui/card-info/card-info.tsx b/src/components/shop-list/ui/card-info/card-info.tsx index 181314fca24e7ab7c849b2f3e421bf96e89c2c29..e0d6632175815cd849b23228d1a37e7082cdac39 100644 --- a/src/components/shop-list/ui/card-info/card-info.tsx +++ b/src/components/shop-list/ui/card-info/card-info.tsx @@ -34,23 +34,6 @@ export const CardInfo: FC = ({ shop }) => { ))} - {/* - ( - - - - {category.name} - - - ))} - /> */} diff --git a/src/components/shop-list/ui/shop-image/styled.tsx b/src/components/shop-list/ui/shop-image/styled.tsx index 49071e312b7d47c578655519112813d514a85311..f7e1805b5026cbedc631e2ef8b99ad1c17497fe6 100644 --- a/src/components/shop-list/ui/shop-image/styled.tsx +++ b/src/components/shop-list/ui/shop-image/styled.tsx @@ -16,7 +16,7 @@ export const ImageWrapper = styled.div` margin-bottom: 2px; overflow: hidden; line-height: 0; - background-color: rgb(var(--black-plus-rgb) 0.44); + background-color: rgb(56 56 56 / 44%); border: 2px solid var(--black-plus); border-radius: 5px; box-shadow: 2px 2px 2px rgb(0 0 0 / 28%); diff --git a/src/app/query-provider.tsx b/src/lib/query-provider.tsx similarity index 63% rename from src/app/query-provider.tsx rename to src/lib/query-provider.tsx index 4ac0076bc22963d12bc0c380d7144143d74ed5de..f2ff60a170f5e651a57e46206600ca4d62505017 100644 --- a/src/app/query-provider.tsx +++ b/src/lib/query-provider.tsx @@ -1,9 +1,13 @@ 'use client'; import { QueryClientProvider, QueryClient } from '@tanstack/react-query'; -import { ReactNode, useState } from 'react'; +import { FC, ReactNode, useState } from 'react'; -const ReactQueryProvider = ({ children }: { children: ReactNode }) => { +interface IReactQueryProvider { + children: ReactNode; +} + +const ReactQueryProvider: FC = ({ children }) => { const [queryClient] = useState(() => new QueryClient()); return ( diff --git a/src/lib/registry.tsx b/src/lib/registry.tsx index d65f43b4b3dbc93c96a0bd193ce41e9ee3e512ec..4e5a9c81c2d69dce2fab32693153acd529a3a53b 100644 --- a/src/lib/registry.tsx +++ b/src/lib/registry.tsx @@ -1,10 +1,15 @@ 'use client'; -import { ReactNode, useState } from 'react'; +import { FC, ReactNode, useState } from 'react'; import { useServerInsertedHTML } from 'next/navigation'; import { ServerStyleSheet, StyleSheetManager } from 'styled-components'; -const StyledComponentsRegistry = ({ children }: { children: ReactNode }) => { +interface IStyledComponentsRegistry { + children: ReactNode; +} +const StyledComponentsRegistry: FC = ({ + children, +}) => { const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet()); useServerInsertedHTML(() => { diff --git a/src/app/store-provider.tsx b/src/lib/store-provider.tsx similarity index 100% rename from src/app/store-provider.tsx rename to src/lib/store-provider.tsx diff --git a/src/shared/back-group/back-group.tsx b/src/shared/back-group/back-group.tsx index 429a2431386d702d4250e10fe48596f16bf27a61..805b881dc93cd5bbf8e979347560d908f3e2b151 100644 --- a/src/shared/back-group/back-group.tsx +++ b/src/shared/back-group/back-group.tsx @@ -1,3 +1,5 @@ +'use client'; + import { FC } from 'react'; import { ActionButton } from '../buttons'; diff --git a/src/shared/form-fields/auth-checkbox/auth-checkbox.tsx b/src/shared/form-fields/auth-checkbox/auth-checkbox.tsx index 1c0f838986290d3b8ed903d49199eeae5e014e89..a9a74a7962ea39d2899c223c8137649fb67877fd 100644 --- a/src/shared/form-fields/auth-checkbox/auth-checkbox.tsx +++ b/src/shared/form-fields/auth-checkbox/auth-checkbox.tsx @@ -1,3 +1,5 @@ +'use client'; + import { FC } from 'react'; import { Control, Controller } from 'react-hook-form'; diff --git a/src/shared/icon/icon.tsx b/src/shared/icon/icon.tsx index 4d51f0021519cc0d3648c6d6e8a45312157fdd00..19cb57fbf06399ef6ba2c5f8ba9f1b7be28ee4ab 100644 --- a/src/shared/icon/icon.tsx +++ b/src/shared/icon/icon.tsx @@ -1,3 +1,5 @@ +'use client'; + import { FC } from 'react'; import * as Styled from './styled'; diff --git a/src/shared/layout-modal/layout-modal.tsx b/src/shared/layout-modal/layout-modal.tsx index 4b93c76f3fd2f6345e55961b81b7ea467620b531..19766ee4805cee709ed1c4df4a12b4921ed85a41 100644 --- a/src/shared/layout-modal/layout-modal.tsx +++ b/src/shared/layout-modal/layout-modal.tsx @@ -1,3 +1,5 @@ +'use client'; + import { FC, ReactNode } from 'react'; import * as Styled from './styled'; diff --git a/src/shared/overlay/overlay.tsx b/src/shared/overlay/overlay.tsx index 17cb9cfa0db80f0739490e51c3429499cb5eb831..1f577b519832eef974cfcaf1a73ec85603f5fad6 100644 --- a/src/shared/overlay/overlay.tsx +++ b/src/shared/overlay/overlay.tsx @@ -1,3 +1,5 @@ +'use client'; + import { FC, ReactNode } from 'react'; import * as Styled from './styled'; diff --git a/src/shared/product-card/product-card.tsx b/src/shared/product-card/product-card.tsx index 3ce916818afa65a9851fbc62391f9877922a6fe8..048d43310817cbb5c9bbe4af643e79b4fb966c66 100644 --- a/src/shared/product-card/product-card.tsx +++ b/src/shared/product-card/product-card.tsx @@ -1,3 +1,5 @@ +'use client'; + import { FC, ReactNode } from 'react'; import * as Styled from './styled'; diff --git a/src/shared/tab/tab.tsx b/src/shared/tab/tab.tsx index 896eb1a03a380c6016953cddffbdfe79832c7671..11faa0a071b2fc3c3d3891390bb69768ab92556f 100644 --- a/src/shared/tab/tab.tsx +++ b/src/shared/tab/tab.tsx @@ -1,3 +1,5 @@ +'use client'; + import { FC } from 'react'; import * as Styled from './styled'; diff --git a/src/app/globals.css b/src/styles/globals.css similarity index 100% rename from src/app/globals.css rename to src/styles/globals.css diff --git a/src/styles/globals.ts b/src/styles/globals.ts deleted file mode 100644 index 4e49df9faba95d3dae0ea7f58fc63a333202c489..0000000000000000000000000000000000000000 --- a/src/styles/globals.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { css } from 'styled-components'; - -export const globals = css` - :root { - * { - margin: 0; - padding: 0; - } - - h1, - h2, - h3 { - margin: 0; - } - - ol, - ul { - list-style: none; - } - - img { - max-width: 100%; - display: block; - } - - a { - text-decoration: none; - color: inherit; - } - - table { - border-collapse: collapse; - border-spacing: 0; - } - - button { - cursor: pointer; - } - - button, - input { - border: none; - background: none; - box-shadow: none; - } - - input[type='text'], - input[type='search'] { - -webkit-appearance: none; - appearance: none; - } - - fieldset { - border: none; - padding: 0; - } - - --family: Gilroy, sans-serif; - --family-secondary: Comfortaa, sans-serif; - - --white: #f3f4f0; - --gray-plus: #bebebe; - --gray: #959595; - --black-plus: #383838; - --black-plus-rgb: 56, 56, 56; - --black: #242424; - --brown: #cb9666; - --pink-plus: #f3b6d1; - --pink: #f669a2; - --red-plus: #fe6a69; - --red: #fa5452; - --yellow-plus: #ffc633; - --yellow: #ffb92a; - --aquamarine-plus: #a5dfdd; - --aquamarine: #23bcc7; - --pistachio: #b1d465; - --green-plus: #5dd1b7; - --green: #51c7a5; - --green-minus: #14ad99; - --violet-plus: #a36ebe; - --violet: #9e50c7; - --blue-plus: #01a9d5; - --blue: #027ec2; - --sky-blue: #a7c3f8; - } -`; diff --git a/src/styles/index.ts b/src/styles/index.ts index 8b1dcca89af7236f7f0f922448ef3386573d6040..33d4737da199ad5e1b166b5f2f105d2a600b3822 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -1,2 +1 @@ export * from './media'; -export * from './globals'; diff --git a/src/vendor/preloader/preloader.tsx b/src/vendor/preloader/preloader.tsx index b38ad294ed64a1ae458910f0acbfd83e3589eb2b..381e4cb4955e82e005fd60adfc33a237be30f593 100644 --- a/src/vendor/preloader/preloader.tsx +++ b/src/vendor/preloader/preloader.tsx @@ -1,6 +1,11 @@ +import { FC } from 'react'; + import * as Styled from './styled'; -export const Preloader = ({ colorItem }: { colorItem: string }) => ( +interface IPreloader { + colorItem?: string; +} +export const Preloader: FC = ({ colorItem = '' }) => ( diff --git a/src/view/catalog-page/catalog-page.tsx b/src/view/catalog-page/catalog-page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..507fd128c6903732ae9431969fa7f94f6edf1771 --- /dev/null +++ b/src/view/catalog-page/catalog-page.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useContext, useEffect, useState } from 'react'; + +import { useCatalog } from '@/api-hooks'; +import { CurrentStylebookContext, ICurrentStylebookContext } from '@/contexts'; +import { getCategories, getErrorMessage } from '@/utils'; +import { Preloader } from '@/vendor'; +import { Catalog } from '@/components'; + +import { getTabIndex } from './utils'; + +export const CatalogPage = ({ + shop, + category, +}: { + shop: string; + category: string; +}) => { + const [selectedTab, setSelectedTab] = useState(0); + const { stylebook, setStylebook } = useContext( + CurrentStylebookContext, + ) as ICurrentStylebookContext; + + const { data, isError, error, isLoading, isSuccess } = useCatalog(shop || ''); + + const categories = isSuccess && data ? getCategories(data) : []; + const index = + isSuccess && categories ? getTabIndex(categories, category || '') : 0; + + useEffect(() => { + if (isSuccess && data) { + setStylebook(data.shop?.stylebook || null); + } + }, [isSuccess, data, setStylebook]); + + useEffect(() => { + if (isSuccess && index) { + setSelectedTab(index); + } + }, [isSuccess, index, setSelectedTab]); + + const handleTabClick = (targetId: number): void => { + setSelectedTab(targetId); + }; + + if (isLoading) return ; + + if (isError) return
Произошла ошибка: {getErrorMessage(error)}
; + + return ( + + ); +}; diff --git a/src/view/catalog-page/index.ts b/src/view/catalog-page/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..49d18ce317f77c5a1f66ae21d39d62bfff3046f0 --- /dev/null +++ b/src/view/catalog-page/index.ts @@ -0,0 +1 @@ +export * from './catalog-page'; diff --git a/src/view/catalog-page/utils/helpers/get-tab-index.ts b/src/view/catalog-page/utils/helpers/get-tab-index.ts new file mode 100644 index 0000000000000000000000000000000000000000..93fc9bcb7454b8e68014bbb18ca10e85b7dbe739 --- /dev/null +++ b/src/view/catalog-page/utils/helpers/get-tab-index.ts @@ -0,0 +1,13 @@ +import { MenuCategory } from '@/interfaces'; + +export const getTabIndex = ( + categories: MenuCategory[], + categoryName: string, +) => { + for (let i = 0; i < categories.length; i += 1) { + if (categories[i].category === categoryName) { + return i; + } + } + return 0; +}; diff --git a/src/view/catalog-page/utils/helpers/index.ts b/src/view/catalog-page/utils/helpers/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..8008390435f1686c97098f916d137bd392810ef3 --- /dev/null +++ b/src/view/catalog-page/utils/helpers/index.ts @@ -0,0 +1 @@ +export * from './get-tab-index'; diff --git a/src/view/catalog-page/utils/index.ts b/src/view/catalog-page/utils/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c5f595cf9d428d32d9ca93ed0bc72174cdb749eb --- /dev/null +++ b/src/view/catalog-page/utils/index.ts @@ -0,0 +1 @@ +export * from './helpers'; diff --git a/src/view/category-page/category-page.tsx b/src/view/category-page/category-page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6ab5a835cdfc4ee544d8b8281c536e7958a512c7 --- /dev/null +++ b/src/view/category-page/category-page.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { useContext, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; + +import { CurrentStylebookContext, ICurrentStylebookContext } from '@/contexts'; +import { useCategory } from '@/api-hooks'; +import { parseToInt, getErrorMessage } from '@/utils'; +import { Preloader } from '@/vendor'; +import { Category } from '@/components'; + +export const CategoryPage = ({ + shop, + indexTabParam, + indexCategoryParam, +}: { + shop: string; + indexTabParam: string; + indexCategoryParam: string; +}) => { + const router = useRouter(); + const { stylebook, setStylebook } = useContext( + CurrentStylebookContext, + ) as ICurrentStylebookContext; + const categoryIndex = parseToInt(indexCategoryParam); + const tabIndex = parseToInt(indexTabParam); + const { newStylebook, category, isError, error, isLoading, isSuccess } = + useCategory(shop || '', tabIndex, categoryIndex); + const handleBackClick = () => { + router.push(`/catalog/${shop}/0`, { scroll: false }); + }; + + useEffect(() => { + if (isSuccess && newStylebook) { + setStylebook(newStylebook || null); + } + }, [isSuccess, newStylebook, setStylebook]); + + if (isLoading) return ; + + if (isError) return
Произошла ошибка: {getErrorMessage(error)}
; + + return ( + + ); +}; diff --git a/src/view/category-page/index.ts b/src/view/category-page/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..2551448041647e0ef4375971bc6472fe0a0e54a3 --- /dev/null +++ b/src/view/category-page/index.ts @@ -0,0 +1 @@ +export * from './category-page'; diff --git a/src/page/index.ts b/src/view/index.ts similarity index 100% rename from src/page/index.ts rename to src/view/index.ts diff --git a/src/view/login-page/index.ts b/src/view/login-page/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..a4d80b3ab6f6d4f617e06ba256ddf3dac178b8e8 --- /dev/null +++ b/src/view/login-page/index.ts @@ -0,0 +1 @@ +export * from './login-page'; diff --git a/src/view/login-page/login-page.tsx b/src/view/login-page/login-page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..67c8d1f92dc8c6d287ab47e41c1e7140261d3fc4 --- /dev/null +++ b/src/view/login-page/login-page.tsx @@ -0,0 +1,3 @@ +import { Login } from '@/components'; + +export const LoginPage = () => ; diff --git a/src/view/product-page/index.ts b/src/view/product-page/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..79ef336aaa91f85898279491a018f39b2a12c617 --- /dev/null +++ b/src/view/product-page/index.ts @@ -0,0 +1 @@ +export * from './product-page'; diff --git a/src/view/product-page/product-page.tsx b/src/view/product-page/product-page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5c889fe6f915bf760482af807aac8579d467ce17 --- /dev/null +++ b/src/view/product-page/product-page.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { FC } from 'react'; + +import { useProduct, useRecommendations } from '@/api-hooks'; +import { getErrorMessage } from '@/utils'; +import { Preloader } from '@/vendor'; +import { Product } from '@/components'; + +interface IProductPage { + shop: string; + item: string; +} +export const ProductPage: FC = ({ shop, item }) => { + const { + mode, + stylebook, + product, + isError: isProductError, + error: productError, + isLoading: isProductLoading, + } = useProduct(shop || '', item || ''); + + const { + data: recommendations, + isError: isRecommendationsError, + error: recommendationsError, + isLoading: isRecommendationsLoading, + } = useRecommendations({ + shop: shop || '', + currentItem: item || '', + }); + + const isLoading = isProductLoading || isRecommendationsLoading; + const isError = isProductError || isRecommendationsError; + const error = productError || recommendationsError; + + if (isLoading) return ; + + if (isError) return
Произошла ошибка: {getErrorMessage(error)}
; + + return ( + + ); +}; diff --git a/src/view/profile-page/index.ts b/src/view/profile-page/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..61fc6857050bfa1cf24d80941dc43284f574b860 --- /dev/null +++ b/src/view/profile-page/index.ts @@ -0,0 +1 @@ +export * from './profile-page'; diff --git a/src/view/profile-page/profile-page.tsx b/src/view/profile-page/profile-page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..045e6e40eb7c6303d62e5fcfb13a9cfcf315f8c4 --- /dev/null +++ b/src/view/profile-page/profile-page.tsx @@ -0,0 +1,3 @@ +import { Profile } from '@/components'; + +export const ProfilePage = () => ; diff --git a/src/view/register-page/index.ts b/src/view/register-page/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..05139564f1cc40d690ac65944c2bf21b41e2d0dc --- /dev/null +++ b/src/view/register-page/index.ts @@ -0,0 +1 @@ +export * from './register-page'; diff --git a/src/view/register-page/register-page.tsx b/src/view/register-page/register-page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bf079a5845bd588d85301e850119ba2115412f7e --- /dev/null +++ b/src/view/register-page/register-page.tsx @@ -0,0 +1,3 @@ +import { Register } from '@/components'; + +export const RegisterPage = () => ; diff --git a/src/page/shop-list-page/index.ts b/src/view/shop-list-page/index.ts similarity index 100% rename from src/page/shop-list-page/index.ts rename to src/view/shop-list-page/index.ts diff --git a/src/page/shop-list-page/shop-list-page.tsx b/src/view/shop-list-page/shop-list-page.tsx similarity index 100% rename from src/page/shop-list-page/shop-list-page.tsx rename to src/view/shop-list-page/shop-list-page.tsx