From a6fbfce9207ea919255ac886cf98ca8fb81e9d4e Mon Sep 17 00:00:00 2001 From: Ivan Angelov Date: Sun, 16 Nov 2025 18:42:29 +0200 Subject: [PATCH 01/13] Designed & Implemented Gallery page Component additions: - Categories filter - Search bar - OrderBy sort - Pagination + Limits --- package-lock.json | 10 +- package.json | 2 +- .../components/layout/header/mobile/navs.ts | 2 +- src/app/components/layout/header/navbar.tsx | 6 +- .../components/search/categories/compobox.tsx | 76 +++++++ .../components/search/categories/index.tsx | 64 ++++++ .../components/search/pagination/index.tsx | 31 +++ .../components/search/pagination/limits.tsx | 44 ++++ .../components/search/pagination/pages.tsx | 79 ++++++++ src/app/components/search/searchbar/index.tsx | 48 +++++ .../components/search/sortings/compobox.tsx | 76 +++++++ src/app/components/search/sortings/index.tsx | 86 ++++++++ src/app/components/ui/pagination.tsx | 166 +++++++++++++++ src/app/components/ui/select.tsx | 191 ++++++++++++++++++ src/app/constants/limits.ts | 1 + src/app/hooks/locales/translations/common.ts | 2 + src/app/locales/bg-BG/common/empty.ts | 5 + .../bg-BG/components/form/placeholders.ts | 7 + src/app/locales/en-GB/common/empty.ts | 5 + .../en-GB/components/form/placeholders.ts | 1 + src/app/locales/types/common.ts | 4 + src/app/locales/types/components/form.ts | 1 + src/app/locales/types/index.ts | 1 + .../public/gallery/hooks/useDropdowns.tsx | 88 ++++++++ src/app/pages/public/gallery/index.tsx | 64 ++++++ src/app/pages/public/gallery/item.tsx | 46 +++++ src/app/pages/public/home/section-1/index.tsx | 2 +- src/hooks/headless/usePagination.ts | 58 ++++-- src/index.css | 4 +- src/routeTree.gen.ts | 24 ++- src/routes/_public/gallery.tsx | 54 +++++ src/types/sorting.ts | 1 - 32 files changed, 1215 insertions(+), 34 deletions(-) create mode 100644 src/app/components/search/categories/compobox.tsx create mode 100644 src/app/components/search/categories/index.tsx create mode 100644 src/app/components/search/pagination/index.tsx create mode 100644 src/app/components/search/pagination/limits.tsx create mode 100644 src/app/components/search/pagination/pages.tsx create mode 100644 src/app/components/search/searchbar/index.tsx create mode 100644 src/app/components/search/sortings/compobox.tsx create mode 100644 src/app/components/search/sortings/index.tsx create mode 100644 src/app/components/ui/pagination.tsx create mode 100644 src/app/components/ui/select.tsx create mode 100644 src/app/constants/limits.ts create mode 100644 src/app/locales/bg-BG/common/empty.ts create mode 100644 src/app/locales/bg-BG/components/form/placeholders.ts create mode 100644 src/app/locales/en-GB/common/empty.ts create mode 100644 src/app/pages/public/gallery/hooks/useDropdowns.tsx create mode 100644 src/app/pages/public/gallery/index.tsx create mode 100644 src/app/pages/public/gallery/item.tsx create mode 100644 src/routes/_public/gallery.tsx delete mode 100644 src/types/sorting.ts diff --git a/package-lock.json b/package-lock.json index 65734cc..6305133 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "customcads-frontend", "version": "0.0.0", "dependencies": { - "@customcads/react-sdk": "^0.1.6", + "@customcads/react-sdk": "^0.1.8", "@icons-pack/react-simple-icons": "^13.8.0", "@lobehub/icons": "^2.43.1", "@microsoft/signalr": "^9.0.6", @@ -879,9 +879,9 @@ } }, "node_modules/@customcads/react-sdk": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@customcads/react-sdk/-/react-sdk-0.1.6.tgz", - "integrity": "sha512-aFo2S/9PKEoo786BdlPVyBQSbjNOQcOMolFGuyuFB4ng4xdWhfqLcbWoWOAZcO3usHaRhtXHq/ShMvkF/DNrVg==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@customcads/react-sdk/-/react-sdk-0.1.8.tgz", + "integrity": "sha512-yrY0Iz3Y3+HN5zU0eb0haUUFLpcBwAtOVHsBFVcza9uxYjjzROjiKpfM+JxEmI5zhoq63AQOy6TjGwrX5wge4w==", "dependencies": { "@tanstack/react-query": "^5.90.5", "axios": "^1.12.2", @@ -2519,6 +2519,8 @@ }, "node_modules/@radix-ui/react-select": { "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", "license": "MIT", "dependencies": { "@radix-ui/number": "1.1.1", diff --git a/package.json b/package.json index e5d882c..faf06d1 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "format": "prettier --write ." }, "dependencies": { - "@customcads/react-sdk": "^0.1.6", + "@customcads/react-sdk": "^0.1.8", "@icons-pack/react-simple-icons": "^13.8.0", "@lobehub/icons": "^2.43.1", "@microsoft/signalr": "^9.0.6", diff --git a/src/app/components/layout/header/mobile/navs.ts b/src/app/components/layout/header/mobile/navs.ts index 25a089b..15ec319 100644 --- a/src/app/components/layout/header/mobile/navs.ts +++ b/src/app/components/layout/header/mobile/navs.ts @@ -6,7 +6,7 @@ type Is = ReturnType; export default { main: [ { name: 'home', url: '/', icon: Home }, - { name: 'gallery', url: '.', icon: Store }, + { name: 'gallery', url: '/gallery', icon: Store }, ], getCollections: (is): SidebarProps['collections'] => { if (is.guest) diff --git a/src/app/components/layout/header/navbar.tsx b/src/app/components/layout/header/navbar.tsx index e3c4d3c..00962af 100644 --- a/src/app/components/layout/header/navbar.tsx +++ b/src/app/components/layout/header/navbar.tsx @@ -18,7 +18,11 @@ const Navbar = () => {
  • - + ; + placeholder?: string; + children?: React.ReactNode; + empty?: string; + onSelect?: (curr?: Value) => void; +}; +const CategoriesCombobox = ({ current, options, ...props }: Props) => { + const [open, setOpen] = useState(false); + + return ( + + {props.children} + + + + + + {props.empty ?? ( +
    + +
    + )} +
    + + {options.map((option) => ( + { + setOpen(false); + const value = options.find( + (x) => x.label === label, + )?.value; + props.onSelect?.(value); + }} + className={cn( + 'bg-accent flex justify-between items-center', + current === option.value + ? 'brightness-90' + : 'hover:brightness-80', + )} + > +
    + + + {option.label} + +
    +
    + ))} +
    +
    +
    +
    +
    + ); +}; + +export default CategoriesCombobox; diff --git a/src/app/components/search/categories/index.tsx b/src/app/components/search/categories/index.tsx new file mode 100644 index 0000000..d2fd2f1 --- /dev/null +++ b/src/app/components/search/categories/index.tsx @@ -0,0 +1,64 @@ +import { useEffect, useState } from 'react'; +import { CategoryResponse, useQuery } from '@customcads/react-sdk'; +import { Funnel, X } from 'lucide-react'; +import ErrorPage from '@/app/components/error'; +import CategoriesCombobox from './compobox'; + +type CategoriesProps = { + getCategory: () => string | undefined; + updateCategory: (category?: CategoryResponse) => void; +}; + +const Categories = ({ getCategory, updateCategory }: CategoriesProps) => { + const { data: categories } = useQuery(({ categories }) => categories.all); + const categoryParam = getCategory(); + + const initial = 'Category'; + const [category, setCategory] = useState(categoryParam ?? initial); + + useEffect(() => { + if (!categoryParam) { + setCategory(initial); + } + }, [initial, categoryParam]); + + if (!categories) { + return ; + } + + const handleSelect = (name?: string) => { + const category = categories.find((c) => c.name === name); + + if (category) { + setCategory(category.name); + updateCategory(category); + } else { + setCategory(initial); + updateCategory(); + } + }; + + const iconClass = 'cursor-pointer w-5 h-5 md:w-5 md:h-5'; + return ( +
    + + ({ + value: x.name, + label: x.name, + }))} + onSelect={handleSelect} + > + + {category} + + + {category !== initial && ( + handleSelect()} className={iconClass} /> + )} +
    + ); +}; + +export default Categories; diff --git a/src/app/components/search/pagination/index.tsx b/src/app/components/search/pagination/index.tsx new file mode 100644 index 0000000..50c511e --- /dev/null +++ b/src/app/components/search/pagination/index.tsx @@ -0,0 +1,31 @@ +import { usePagination } from '@/hooks/headless/usePagination'; +import * as pagination from '@/app/components/ui/pagination'; +import Limits from './limits'; +import Pages from './pages'; + +type Props = { + total: number; + defaultPagination: { page: number; limit: number }; + navigate: (pagination: { page: number; limit: number }) => void; +}; +const Pagination = ({ total, defaultPagination, navigate }: Props) => { + const { + state: { page, limit }, + handleChange, + } = usePagination({ total, defaultPagination, onChange: navigate }); + + return ( + + + + + + + ); +}; + +export default Pagination; diff --git a/src/app/components/search/pagination/limits.tsx b/src/app/components/search/pagination/limits.tsx new file mode 100644 index 0000000..7e6bf3c --- /dev/null +++ b/src/app/components/search/pagination/limits.tsx @@ -0,0 +1,44 @@ +import { cn } from '@/lib/utils/tailwindcss'; +import * as pagination from '@/app/components/ui/pagination'; +import * as select from '@/app/components/ui/select'; +import * as LIMITS from '@/app/constants/limits'; + +type Props = { + limit: number; + onChange: (limit: number) => void; +}; +const Limits = ({ limit, onChange }: Props) => { + const renderLimits = () => { + const limits = []; + for (let i = LIMITS.GALLERY.min; i <= LIMITS.GALLERY.max; i++) { + limits.push( + + {i} + , + ); + } + return limits; + }; + + return ( + + onChange(Number(val))}> + + + + + {renderLimits()} + + + + ); +}; + +export default Limits; diff --git a/src/app/components/search/pagination/pages.tsx b/src/app/components/search/pagination/pages.tsx new file mode 100644 index 0000000..a87a866 --- /dev/null +++ b/src/app/components/search/pagination/pages.tsx @@ -0,0 +1,79 @@ +import { cn } from '@/lib/utils/tailwindcss'; +import * as pagination from '@/app/components/ui/pagination'; + +type Props = { + current: number; + last: number; + onChange: (page: number) => void; +}; +const Pages = ({ current, last, onChange }: Props) => { + const renderPages = () => { + const pages = []; + for (let i = 1; i <= last; i++) { + pages.push( + + onChange(i)} + className={cn('text-sm md:text-lg')} + isActive={current === i} + > + {i} + + , + ); + } + return pages; + }; + const handle = { + beginning: () => onChange(1), + previous: () => onChange(current - 1), + next: () => onChange(current + 1), + end: () => onChange(last), + }; + + const isFirst = current === 1; + const isLast = current === last; + + return [ + + + , + + + , + renderPages(), + + + , + + + , + ]; +}; + +export default Pages; diff --git a/src/app/components/search/searchbar/index.tsx b/src/app/components/search/searchbar/index.tsx new file mode 100644 index 0000000..d5729c4 --- /dev/null +++ b/src/app/components/search/searchbar/index.tsx @@ -0,0 +1,48 @@ +import { useState } from 'react'; +import { Search, X } from 'lucide-react'; +import { Input } from '@/app/components/ui/input'; + +type SearchbarProps = { + placeholder: string; + getSearch: () => string | undefined; + updateSearch: (searchTerm: string | undefined) => void; +}; + +const Searchbar = ({ + placeholder, + getSearch, + updateSearch, +}: SearchbarProps) => { + const [search, setSearch] = useState(getSearch()); + + return ( +
    + {search && ( + { + setSearch(undefined); + updateSearch(undefined); + }} + className='cursor-pointer' + /> + )} + setSearch(target.value)} + onBlur={() => updateSearch(search)} + onKeyDown={({ key }) => key === 'Enter' && updateSearch(search)} + className='bg-secondary border-2 rounded-xl min-h-11 md:min-h-14 md:px-6 text-ellipsis text-xs md:text-lg' + autoComplete='off' + /> + updateSearch(search)} + className='cursor-pointer' + /> +
    + ); +}; + +export default Searchbar; diff --git a/src/app/components/search/sortings/compobox.tsx b/src/app/components/search/sortings/compobox.tsx new file mode 100644 index 0000000..e51f92f --- /dev/null +++ b/src/app/components/search/sortings/compobox.tsx @@ -0,0 +1,76 @@ +'use client'; +import { useState } from 'react'; +import { CheckIcon, Loader } from 'lucide-react'; +import { cn } from '@/lib/utils/tailwindcss'; +import * as command from '@/app/components/ui/command'; +import * as popover from '@/app/components/ui/popover'; + +type Value = string; +type Props = { + current?: Value; + options: Array<{ value: Value; label: string }>; + placeholder?: string; + children?: React.ReactNode; + empty?: string; + onSelect?: (curr?: Value) => void; +}; +const SortingsCombobox = ({ current, options, ...props }: Props) => { + const [open, setOpen] = useState(false); + + return ( + + {props.children} + + + + + + {props.empty ?? ( +
    + +
    + )} +
    + + {options.map((option) => ( + { + setOpen(false); + const value = options.find( + (x) => x.label === label, + )?.value; + props.onSelect?.(value); + }} + className={cn( + 'bg-accent flex justify-between items-center', + current === option.value + ? 'brightness-90' + : 'hover:brightness-80', + )} + > +
    + + + {option.label} + +
    +
    + ))} +
    +
    +
    +
    +
    + ); +}; + +export default SortingsCombobox; diff --git a/src/app/components/search/sortings/index.tsx b/src/app/components/search/sortings/index.tsx new file mode 100644 index 0000000..9bfc701 --- /dev/null +++ b/src/app/components/search/sortings/index.tsx @@ -0,0 +1,86 @@ +import { useEffect, useState } from 'react'; +import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react'; +import ErrorPage from '@/app/components/error'; +import SortingsCombobox from './compobox'; + +export type SortingDirection = 'ascending' | 'descending'; +type SortingsProps = { + getSorting: () => { type?: string; direction?: string }; + updateSorting: (sorting: { type?: string; direction?: string }) => void; + sortings: string[]; +}; + +const Sortings = ({ getSorting, updateSorting, sortings }: SortingsProps) => { + const { type: sortingParam, direction: directionParam } = getSorting(); + const initial = 'Sort By'; + + const [sorting, setSorting] = useState(sortingParam ?? initial); + const [direction, setDirection] = useState( + directionParam ? (directionParam as SortingDirection) : 'descending', + ); + + useEffect(() => { + if (!sortingParam) { + setSorting(initial); + } + }, [initial, sortingParam]); + + useEffect(() => { + updateSorting({ + type: sortingParam, + direction: directionParam, + }); + }, [sortingParam]); + + const toggleDirection = () => { + if (sorting !== initial) { + setDirection((prev) => { + const set = (direction: SortingDirection) => { + updateSorting({ direction: direction }); + return direction; + }; + switch (prev) { + case 'ascending': + return set('descending'); + case 'descending': + default: + return set('ascending'); + } + }); + } + }; + + const handleSelect = (name?: string) => { + setSorting(() => name ?? initial); + updateSorting({ type: name, direction }); + }; + + if (!sortings) { + return ; + } + + const DirectionArrow = direction === 'ascending' ? ArrowUp : ArrowDown; + + return ( +
    + + ({ + value: x, + label: x, + }))} + onSelect={handleSelect} + > + + {sorting} + + + {sorting !== initial && ( + + )} +
    + ); +}; + +export default Sortings; diff --git a/src/app/components/ui/pagination.tsx b/src/app/components/ui/pagination.tsx new file mode 100644 index 0000000..488cc1b --- /dev/null +++ b/src/app/components/ui/pagination.tsx @@ -0,0 +1,166 @@ +import * as React from 'react'; +import { + ChevronLeftIcon, + ChevronRightIcon, + ChevronsLeftIcon, + ChevronsRightIcon, + MoreHorizontalIcon, +} from 'lucide-react'; +import { cn } from '@/lib/utils/tailwindcss'; +import { Button, buttonVariants } from '@/app/components/ui/button'; + +const iconStyle = { width: '1.25rem', height: '1.25rem' }; +const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => { + return ( +
  • { useThemeSync(); useAuthSync(); useLanguagesSync(); + useCartSync(); return (
    diff --git a/src/app/hooks/features/carts/useCartTotalCalculator.ts b/src/app/hooks/features/carts/useCartCalculator.ts similarity index 85% rename from src/app/hooks/features/carts/useCartTotalCalculator.ts rename to src/app/hooks/features/carts/useCartCalculator.ts index f04b374..2b18dc1 100644 --- a/src/app/hooks/features/carts/useCartTotalCalculator.ts +++ b/src/app/hooks/features/carts/useCartCalculator.ts @@ -1,6 +1,6 @@ import { useState } from 'react'; -export const useCartTotalCalculator = () => { +export const useCartCalculator = () => { const [money, setMoney] = useState>({}); return [ diff --git a/src/app/hooks/features/carts/useCartUpdates.ts b/src/app/hooks/features/carts/useCartUpdates.ts index c8fc6d9..b47280e 100644 --- a/src/app/hooks/features/carts/useCartUpdates.ts +++ b/src/app/hooks/features/carts/useCartUpdates.ts @@ -4,7 +4,7 @@ import { useIdempotencyKeys } from '@/app/hooks/features/idempotency-keys/useIde import { useAuthStore } from '@/app/hooks/stores/useAuthStore'; import { useCartStore } from '@/app/hooks/stores/useCartStore'; -export const useCartUpdates = (): typeof actions => { +export const useCartUpdates = () => { const { is } = useAuthStore(); const { idempotencyKeys } = useIdempotencyKeys(['add']); @@ -26,7 +26,6 @@ export const useCartUpdates = (): typeof actions => { ); return { - ...actions, cart: { fill: actions.cart.fill, clear: actions.cart.clear, diff --git a/src/app/hooks/stores/useCartSync.ts b/src/app/hooks/stores/useCartSync.ts index e9ec1ed..d911075 100644 --- a/src/app/hooks/stores/useCartSync.ts +++ b/src/app/hooks/stores/useCartSync.ts @@ -20,8 +20,8 @@ const mapItems = (items: ActiveCartItem[]) => ); export const useCartSync = () => { - const { items, actions } = useCartStore(); const { is } = useAuthStore(); + const { items, actions } = useCartStore(); const activeCart = useQuery(({ activeCarts }) => activeCarts.all, false); diff --git a/src/app/locales/bg-BG/pages/public/gallery/cart.ts b/src/app/locales/bg-BG/pages/public/gallery/cart.ts new file mode 100644 index 0000000..2d59fb1 --- /dev/null +++ b/src/app/locales/bg-BG/pages/public/gallery/cart.ts @@ -0,0 +1,14 @@ +import { Cart } from '@/app/locales/types/pages/public/gallery'; + +export default { + title: 'Вашата Количка', + view: 'Разгледай', + customize: 'Персонализирайте', + delivery: 'Принт + Пратка', + undeliverable: 'Неподходящо за Принт/Пратка', + 'product-price': 'Цена на Продукт', + 'print-cost': 'Разход за Принтиране', + 'total-sum': 'Обща сума', + buy: 'Купи', + 'no-items': 'Добави Продукти към тази Количка', +} satisfies Cart; diff --git a/src/app/locales/bg-BG/pages/public/gallery/product.ts b/src/app/locales/bg-BG/pages/public/gallery/product.ts index b9efc51..9f0b2b0 100644 --- a/src/app/locales/bg-BG/pages/public/gallery/product.ts +++ b/src/app/locales/bg-BG/pages/public/gallery/product.ts @@ -12,4 +12,8 @@ export default { 'add-short': 'Добави', added: 'Добавено към Количката', 'added-short': 'Добавено', + 'add_delivery-question': + 'Бихте ли желали 3D Моделът да бъде доставен до Вас?', + 'add_no-delivery': 'Не, нуждая се само от файла.', + 'add_for-delivery': 'Да, искам принтирана пратка.', } satisfies Product; diff --git a/src/app/locales/en-GB/pages/public/gallery/cart.ts b/src/app/locales/en-GB/pages/public/gallery/cart.ts new file mode 100644 index 0000000..df1adf5 --- /dev/null +++ b/src/app/locales/en-GB/pages/public/gallery/cart.ts @@ -0,0 +1,14 @@ +import { Cart } from '@/app/locales/types/pages/public/gallery'; + +export default { + title: 'Your Cart', + view: 'View', + customize: 'Customize', + delivery: 'Print & Ship', + undeliverable: 'Not suitable for Print/Ship', + 'product-price': 'Product price', + 'print-cost': 'Print cost', + 'total-sum': 'Total sum', + buy: 'Buy', + 'no-items': 'Add Products to this Cart', +} satisfies Cart; diff --git a/src/app/locales/en-GB/pages/public/gallery/product.ts b/src/app/locales/en-GB/pages/public/gallery/product.ts index 39a763b..6878e74 100644 --- a/src/app/locales/en-GB/pages/public/gallery/product.ts +++ b/src/app/locales/en-GB/pages/public/gallery/product.ts @@ -12,4 +12,7 @@ export default { 'add-short': 'Add', added: 'Added to Cart', 'added-short': 'Added', + 'add_delivery-question': 'Would you like the 3D Model delivered to you?', + 'add_no-delivery': 'No, I need only the file.', + 'add_for-delivery': 'Yes, I want it a printed shipment.', } satisfies Product; diff --git a/src/app/locales/types/pages/public/gallery.ts b/src/app/locales/types/pages/public/gallery.ts index 2422790..44dbb4b 100644 --- a/src/app/locales/types/pages/public/gallery.ts +++ b/src/app/locales/types/pages/public/gallery.ts @@ -10,24 +10,20 @@ export type Product = { 'add-short': string; added: string; 'added-short': string; + 'add_delivery-question': string; + 'add_no-delivery': string; + 'add_for-delivery': string; }; export type Cart = { title: string; - by: string; view: string; customize: string; delivery: string; + undeliverable: string; 'product-price': string; - 'customization-cost': string; - remove: string; - total?: undefined; - total_zero: string; - total_one: string; - total_other: string; - 'total-delivery'?: undefined; - 'total-delivery_zero': string; - 'total-delivery_one': string; - 'total-delivery_other': string; + 'print-cost': string; + 'total-sum': string; buy: string; + 'no-items': string; }; diff --git a/src/app/pages/public/cart/aside.tsx b/src/app/pages/public/cart/aside.tsx new file mode 100644 index 0000000..dd78089 --- /dev/null +++ b/src/app/pages/public/cart/aside.tsx @@ -0,0 +1,61 @@ +import { Link } from '@tanstack/react-router'; +import { useGalleryTranslations } from '@/app/hooks/locales/translations/pages/public'; +import { useCartStore } from '@/app/hooks/stores/useCartStore'; +import { useMoney } from '@/app/hooks/locales/useMoney'; +import { Button } from '@/app/components/ui/button'; +import Money from './money'; + +type MoneyRecord = Record; +type Props = { prices: MoneyRecord; costs: MoneyRecord }; +const Aside = ({ prices, costs }: Props) => { + const tCart = useGalleryTranslations('cart'); + const { items } = useCartStore(); + + const calculate = (money: Record) => + Object.values(money).reduce((price, acc) => acc + price, 0); + + const sum = { + prices: calculate(prices), + costs: calculate(costs), + }; + + const money = { + product: useMoney(sum.prices), + print: useMoney(sum.costs), + total: useMoney(sum.prices + sum.costs), + }; + + // TODO: Update when Payment is implemented + const content = items?.length ? ( +
    + + + + +
    + ) : ( +
    + + {'Payment is currently still not supported.'} + + {/* + {tCart('no-items')} + */} +
    + ); + + return ( + + ); +}; + +export default Aside; diff --git a/src/app/pages/public/cart/index.tsx b/src/app/pages/public/cart/index.tsx new file mode 100644 index 0000000..dfa25e4 --- /dev/null +++ b/src/app/pages/public/cart/index.tsx @@ -0,0 +1,42 @@ +import { cn } from '@/lib/utils/tailwindcss'; +import { useGalleryTranslations } from '@/app/hooks/locales/translations/pages/public'; +import { useCartCalculator } from '@/app/hooks/features/carts/useCartCalculator'; +import * as page from '@/app/utils/page'; +import List from './list'; +import Aside from './aside'; + +const Cart = () => { + const tCart = useGalleryTranslations('cart'); + + const [prices, setPrice] = useCartCalculator(); + const [costs, setCost] = useCartCalculator(); + + return ( +
    +
    +
    +

    + {tCart('title')} +

    + + setPrice({ + id: productId, + set: (prev) => (acc ? prev + price : price), + }), + cost: (id, cost, acc?: boolean) => + setCost({ + id: id, + set: (prev) => (acc ? prev + cost : cost), + }), + }} + /> +
    +
    +
    + ); +}; + +export default Cart; diff --git a/src/app/pages/public/cart/item/buttons.tsx b/src/app/pages/public/cart/item/buttons.tsx new file mode 100644 index 0000000..6b39431 --- /dev/null +++ b/src/app/pages/public/cart/item/buttons.tsx @@ -0,0 +1,65 @@ +import { Trash } from 'lucide-react'; +import { useGalleryTranslations } from '@/app/hooks/locales/translations/pages/public'; +import { Checkbox } from '@/app/components/ui/checkbox'; +import { Button } from '@/app/components/ui/button'; +import { Children } from '@/types/react'; + +type Props = { + forDelivery?: boolean; + isPrintable?: boolean; + onRemoveClick: VoidFunction; + onDeliveryChange: VoidFunction; +} & Children; +const Buttons = ({ + children, + forDelivery, + isPrintable, + onDeliveryChange, + onRemoveClick, +}: Props) => { + const tCart = useGalleryTranslations('cart'); + + const ui = { + remove: ( + + ), + delivery: isPrintable ? ( + + ) : ( + + ), + }; + + return ( +
    +
    + {ui.delivery} + {children} + {ui.remove} +
    +
    + ); +}; + +export default Buttons; diff --git a/src/app/pages/public/cart/item/for-delivery.tsx b/src/app/pages/public/cart/item/for-delivery.tsx new file mode 100644 index 0000000..183f50c --- /dev/null +++ b/src/app/pages/public/cart/item/for-delivery.tsx @@ -0,0 +1,96 @@ +import { useQuery } from '@customcads/react-sdk'; +import type { CartItemForDelivery } from '@/app/types/cart-item'; +import { useCartUpdates } from '@/app/hooks/features/carts/useCartUpdates'; +import Loader from '@/app/components/loading'; +import { useItemMoney } from './hooks/useItemMoney'; +import Quantity from './quantity'; +import { useCartQuantitySync } from './hooks/useCartQuantitySync'; +import Buttons from './buttons'; +import Header from './header'; +import Money from '../money'; + +type Props = { + item: CartItemForDelivery; + reset: { price: VoidFunction; cost: VoidFunction }; + addTo: { price: (price: number) => void; cost: (cost: number) => void }; +}; +const ItemForDelivery = ({ item, ...props }: Props) => { + const updates = useCartUpdates(); + + const { data: product } = useQuery(({ products }) => + products.gallery.single({ id: item.productId }), + ); + useCartQuantitySync({ + reset: props.reset.price, + add: () => product && props.addTo.price(product.price), + deps: [product], + }); + + const { data: customization } = useQuery(({ customizations }) => + customizations.single({ id: item.customizationId }), + ); + useCartQuantitySync({ + reset: props.reset.cost, + add: () => customization && props.addTo.cost(customization.cost), + deps: [customization], + }); + + const money = useItemMoney({ + product: product?.price ?? 0, + print: customization?.cost ?? 0, + }); + if (!product || !customization) return ; + + const remove = () => { + props.addTo.price(-product.price * item.quantity); + props.addTo.cost(-customization.cost * item.quantity); + updates.cart.remove(item.productId); + }; + + const setNoDelivery = async () => { + props.addTo.price(-1 * product.price * (item.quantity - 1)); + props.addTo.cost(-1 * customization.cost * item.quantity); + await updates.item.delivery.off(item.productId); + }; + + const quantity = { + increment: async () => { + props.addTo.price(product.price); + props.addTo.cost(customization.cost); + await updates.item.quantity.increment(item.productId); + }, + decrement: async () => { + if (item.quantity === 1) return; + props.addTo.price(-1 * product.price); + props.addTo.cost(-1 * customization.cost); + await updates.item.quantity.decrement(item.productId); + }, + }; + + return ( +
    +
    +
    +
    +
    + +
    +
    + + + +
    +
    + ); +}; + +export default ItemForDelivery; diff --git a/src/app/pages/public/cart/item/header.tsx b/src/app/pages/public/cart/item/header.tsx new file mode 100644 index 0000000..846563f --- /dev/null +++ b/src/app/pages/public/cart/item/header.tsx @@ -0,0 +1,17 @@ +import { GallerySingleProductResponse, useQuery } from '@customcads/react-sdk'; + +type Props = { product: GallerySingleProductResponse }; +const Header = ({ product }: Props) => { + const { data: image } = useQuery(({ images }) => + images.download({ id: product.imageId, relationType: 'Product' }), + ); + + return ( +
    +

    {product.name}

    + +
    + ); +}; + +export default Header; diff --git a/src/app/pages/public/cart/item/hooks/useCartQuantitySync.ts b/src/app/pages/public/cart/item/hooks/useCartQuantitySync.ts new file mode 100644 index 0000000..6bf05c7 --- /dev/null +++ b/src/app/pages/public/cart/item/hooks/useCartQuantitySync.ts @@ -0,0 +1,8 @@ +import { DependencyList, useEffect } from 'react'; + +type Props = { reset: VoidFunction; add: VoidFunction; deps?: DependencyList }; +export const useCartQuantitySync = ({ reset, add, deps }: Props) => + useEffect(() => { + reset(); + add(); + }, deps); diff --git a/src/app/pages/public/cart/item/hooks/useItemMoney.ts b/src/app/pages/public/cart/item/hooks/useItemMoney.ts new file mode 100644 index 0000000..e2be8af --- /dev/null +++ b/src/app/pages/public/cart/item/hooks/useItemMoney.ts @@ -0,0 +1,8 @@ +import { useMoney } from '@/app/hooks/locales/useMoney'; + +type Props = { product: number; print: number }; +export const useItemMoney = ({ product, print }: Props) => ({ + product: useMoney(product), + print: useMoney(print), + total: useMoney(product + print), +}); diff --git a/src/app/pages/public/cart/item/index.tsx b/src/app/pages/public/cart/item/index.tsx new file mode 100644 index 0000000..90473c5 --- /dev/null +++ b/src/app/pages/public/cart/item/index.tsx @@ -0,0 +1,47 @@ +import { CartItem } from '@/app/types/cart-item'; +import ItemNoDelivery from './no-delivery'; +import ItemForDelivery from './for-delivery'; + +type Props = { + item: CartItem; + set: { + price: (val: number, acc?: boolean) => void; + cost: (val: number, acc?: boolean) => void; + }; +}; +const Item = ({ item, set }: Props) => { + const price = { + reset: () => set.price(0), + addTo: (price: number) => set.price(price, true), + }; + if (!item.forDelivery) + return ( + + ); + + const cost = { + reset: () => set.cost(0), + addTo: (cost: number) => set.cost(cost, true), + }; + return ( + + ); +}; + +export default Item; diff --git a/src/app/pages/public/cart/item/no-delivery.tsx b/src/app/pages/public/cart/item/no-delivery.tsx new file mode 100644 index 0000000..79ece21 --- /dev/null +++ b/src/app/pages/public/cart/item/no-delivery.tsx @@ -0,0 +1,67 @@ +import { useNavigate } from '@tanstack/react-router'; +import { useQuery } from '@customcads/react-sdk'; +import type { CartItemNoDelivery } from '@/app/types/cart-item'; +import { useCartUpdates } from '@/app/hooks/features/carts/useCartUpdates'; +import Loader from '@/app/components/loading'; +import { is } from '@/app/utils/product-tags'; +import { useItemMoney } from './hooks/useItemMoney'; +import { useCartQuantitySync } from './hooks/useCartQuantitySync'; +import Money from '../money'; +import Header from './header'; +import Buttons from './buttons'; + +type Props = { + item: CartItemNoDelivery; + resetPrice: VoidFunction; + addToPrice: (price: number) => void; +}; +const ItemNoDelivery = ({ item, ...props }: Props) => { + const navigate = useNavigate(); + const updates = useCartUpdates(); + + const { data: product } = useQuery(({ products }) => + products.gallery.single({ id: item.productId }), + ); + useCartQuantitySync({ + reset: props.resetPrice, + add: () => product && props.addToPrice(product.price), + deps: [product], + }); + + const money = useItemMoney({ + product: product?.price ?? 0, + print: 0, + }); + + if (!product) return ; + + const remove = () => { + props.addToPrice(-product.price); + updates.cart.remove(item.productId); + }; + + return ( +
    +
    +
    +
    +
    + +
    +
    + + navigate({ + to: '/editor/$id', + params: { id: product.id }, + }) + } + onRemoveClick={remove} + /> +
    +
    + ); +}; + +export default ItemNoDelivery; diff --git a/src/app/pages/public/cart/item/quantity.tsx b/src/app/pages/public/cart/item/quantity.tsx new file mode 100644 index 0000000..6a8d831 --- /dev/null +++ b/src/app/pages/public/cart/item/quantity.tsx @@ -0,0 +1,21 @@ +import { Minus, Plus } from 'lucide-react'; +import { Button } from '@/app/components/ui/button'; + +type Props = { + value: number; + increment: VoidFunction; + decrement: VoidFunction; +}; +const Quantity = ({ value, increment, decrement }: Props) => ( +
    + + {value} + +
    +); + +export default Quantity; diff --git a/src/app/pages/public/cart/list.tsx b/src/app/pages/public/cart/list.tsx new file mode 100644 index 0000000..a5c1e32 --- /dev/null +++ b/src/app/pages/public/cart/list.tsx @@ -0,0 +1,47 @@ +import { Link } from '@tanstack/react-router'; +import { useGalleryTranslations } from '@/app/hooks/locales/translations/pages/public'; +import { useCartStore } from '@/app/hooks/stores/useCartStore'; +import { ScrollArea } from '@/app/components/ui/scroll-area'; +import Item from './item'; + +type Set = (productId: string, cost: number, acc?: boolean) => void; +type Props = { set: { price: Set; cost: Set } }; +const List = ({ set }: Props) => { + const tCart = useGalleryTranslations('cart'); + const { items } = useCartStore(); + + return ( +
    + +
      + {items?.length ? ( + items.map((x) => ( +
    • + + set.cost(x.productId, cost, acc), + price: (price, acc?: boolean) => + set.price(x.productId, price, acc), + }} + /> +
    • + )) + ) : ( +
      + + {tCart('no-items')} + +
      + )} +
    +
    +
    + ); +}; + +export default List; diff --git a/src/app/pages/public/cart/money.tsx b/src/app/pages/public/cart/money.tsx new file mode 100644 index 0000000..3f393d8 --- /dev/null +++ b/src/app/pages/public/cart/money.tsx @@ -0,0 +1,40 @@ +import { useGalleryTranslations } from '@/app/hooks/locales/translations/pages/public'; + +type Props = { + money: { product: string; print: string; total: string }; + className?: string; + separator?: string; + withSigns?: boolean; +}; +const Money = ({ + className, + separator = ': ', + withSigns, + money: { product, print, total }, +}: Props) => { + const tCart = useGalleryTranslations('cart'); + + return ( +
    +

    + {tCart('product-price')} + {separator} + {product} +

    + {withSigns && +} +

    + {tCart('print-cost')} + {separator} + {print} +

    + {withSigns && =} +

    + {tCart('total-sum')} + {separator} + {total} +

    +
    + ); +}; + +export default Money; diff --git a/src/app/stores/cart.ts b/src/app/stores/cart.ts index 70d0e84..734c8a4 100644 --- a/src/app/stores/cart.ts +++ b/src/app/stores/cart.ts @@ -98,7 +98,7 @@ export const actions = { if (prev.items.find((item) => item.productId === id)) { return { items: prev.items.map((item) => { - if (item.productId === id && item.forDelivery) + if (item.productId === id && !item.forDelivery) return { ...item, forDelivery: true, diff --git a/src/app/types/cart-item.ts b/src/app/types/cart-item.ts index 110f467..6e151d3 100644 --- a/src/app/types/cart-item.ts +++ b/src/app/types/cart-item.ts @@ -5,9 +5,9 @@ export type CartItemForDelivery = { customizationId: string; }; -export type CartItemWithoutDelivery = { +export type CartItemNoDelivery = { forDelivery: false; productId: string; }; -export type CartItem = CartItemForDelivery | CartItemWithoutDelivery; +export type CartItem = CartItemForDelivery | CartItemNoDelivery; diff --git a/src/app/utils/product-tags.ts b/src/app/utils/product-tags.ts new file mode 100644 index 0000000..6302b6c --- /dev/null +++ b/src/app/utils/product-tags.ts @@ -0,0 +1,13 @@ +export const is = (tags: string[]) => { + const is = { + printable: false, + professional: false, + popular: false, + }; + + if (tags.includes('Printable')) is.printable = true; + if (tags.includes('Professional')) is.professional = true; + if (tags.includes('Popular')) is.popular = true; + + return is; +}; From bccc604a9cdbb3f0d5f15210eb56c4f822fb58ca Mon Sep 17 00:00:00 2001 From: Ivan Angelov Date: Mon, 8 Dec 2025 01:36:01 +0200 Subject: [PATCH 09/13] Revolutionized Store persistence --- src/app/constants/stores.ts | 5 ++++ src/app/hooks/stores/useCartSync.ts | 14 +++++++++-- src/app/stores/cart.ts | 24 ++++++++----------- src/app/stores/currency.ts | 32 +++++++++++-------------- src/app/stores/language.ts | 24 ++++++++----------- src/app/stores/theme.ts | 28 ++++++++++------------ src/lib/isomorphic/api.ts | 13 +++------- src/lib/isomorphic/cart.ts | 8 +++++++ src/lib/isomorphic/language.ts | 17 +++++++------ src/lib/isomorphic/persistence/get.ts | 14 +++++++++++ src/lib/isomorphic/persistence/index.ts | 2 ++ src/lib/isomorphic/persistence/set.ts | 32 +++++++++++++++++++++++++ src/lib/isomorphic/theme.ts | 21 ++++++---------- src/types/locale.ts | 2 ++ 14 files changed, 140 insertions(+), 96 deletions(-) create mode 100644 src/app/constants/stores.ts create mode 100644 src/lib/isomorphic/cart.ts create mode 100644 src/lib/isomorphic/persistence/get.ts create mode 100644 src/lib/isomorphic/persistence/index.ts create mode 100644 src/lib/isomorphic/persistence/set.ts diff --git a/src/app/constants/stores.ts b/src/app/constants/stores.ts new file mode 100644 index 0000000..5798e32 --- /dev/null +++ b/src/app/constants/stores.ts @@ -0,0 +1,5 @@ +export const CART = { store: 'cart-store', cookie: 'cart' }; +export const CURRENCY = { store: 'currency-store', cookie: 'currency' }; +export const EDITOR = { store: 'editor-store' }; +export const LANGUAGE = { store: 'language-store', cookie: 'language' }; +export const THEME = { store: 'theme-store', cookie: 'theme' }; diff --git a/src/app/hooks/stores/useCartSync.ts b/src/app/hooks/stores/useCartSync.ts index d911075..4fa4001 100644 --- a/src/app/hooks/stores/useCartSync.ts +++ b/src/app/hooks/stores/useCartSync.ts @@ -1,9 +1,10 @@ import { useEffect } from 'react'; import { type ActiveCartItem, useQuery } from '@customcads/react-sdk'; +import { getCartCookie } from '@/lib/isomorphic/cart'; import { CartItem } from '@/app/types/cart-item'; +import * as cartStore from '@/app/stores/cart'; import { useAuthStore } from './useAuthStore'; import { useCartStore } from './useCartStore'; -import { persistLocally } from '@/app/stores/cart'; const mapItems = (items: ActiveCartItem[]) => items.map( @@ -19,15 +20,24 @@ const mapItems = (items: ActiveCartItem[]) => }, ); +const cookieSync = ( + items: CartItem[] | null, + fill: (items: CartItem[]) => void, +) => { + const cookie = getCartCookie(); + if (cookie !== null && items?.length !== cookie.length) fill(cookie); +}; + export const useCartSync = () => { const { is } = useAuthStore(); const { items, actions } = useCartStore(); + cookieSync(items, actions.cart.fill); const activeCart = useQuery(({ activeCarts }) => activeCarts.all, false); useEffect(() => { if (is.guest) { - persistLocally({ items }); + cartStore.persist({ items }); } }, [items]); diff --git a/src/app/stores/cart.ts b/src/app/stores/cart.ts index 734c8a4..511af01 100644 --- a/src/app/stores/cart.ts +++ b/src/app/stores/cart.ts @@ -1,22 +1,20 @@ import { Store } from '@tanstack/store'; -import Cookies from 'js-cookie'; +import * as persistence from '@/lib/isomorphic/persistence'; import { getEnv } from '@/lib/isomorphic/env'; import { getRoleCookie } from '@/lib/isomorphic/api'; import { is } from '@/lib/utils/auth'; import { CartItem } from '@/app/types/cart-item'; +import { CART } from '@/app/constants/stores'; const { customer: isCustomer } = is({ authn: !!getRoleCookie(), authz: getRoleCookie() ?? null, }); -const LOCAL_STORAGE_KEY = 'cart-store'; -const COOKIE_STORAGE_KEY = 'cart'; - -export const persistLocally = (state: CartState) => { - localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(state)); - Cookies.set(COOKIE_STORAGE_KEY, JSON.stringify(state.items)); -}; +export const persist = persistence.create(CART.store, (state) => ({ + data: state.items, + key: CART.cookie, +})); type CartState = { items: CartItem[] | null; @@ -25,18 +23,16 @@ const defaultState = (): CartState => { if (getEnv().isServer) return { items: null }; if (isCustomer) return { items: null }; - const persistedState = localStorage.getItem(LOCAL_STORAGE_KEY); - if (persistedState) { - return JSON.parse(persistedState); - } + const persistedState = persistence.get(CART.store); + if (persistedState) return JSON.parse(persistedState); const state = { items: [] }; - persistLocally(state); + persist(state); return state; }; export const store = new Store(defaultState()); -store.subscribe(({ currentVal }) => persistLocally(currentVal)); +store.subscribe(({ currentVal }) => persist(currentVal)); export const actions = { cart: { diff --git a/src/app/stores/currency.ts b/src/app/stores/currency.ts index bd6afaf..9e374b6 100644 --- a/src/app/stores/currency.ts +++ b/src/app/stores/currency.ts @@ -1,41 +1,37 @@ import { Store } from '@tanstack/store'; import { Currency } from '@customcads/react-sdk'; -import Cookies from 'js-cookie'; +import * as persistence from '@/lib/isomorphic/persistence'; import { getEnv } from '@/lib/isomorphic/env'; +import { CURRENCY } from '@/app/constants/stores'; import * as money from '@/app/utils/money'; -const LOCAL_STORAGE_KEY = 'currency-store'; -const COOKIE_STORAGE_KEY = 'currency'; +const persist = persistence.create(CURRENCY.store, (state) => ({ + data: state.current, + key: CURRENCY.cookie, +})); const defaultBrowserCurrency = money.resolveCurrency(); type CurrencyState = { current: Currency; }; -export const defaultState: CurrencyState = { - current: defaultBrowserCurrency, -}; const loadInitialState = (): CurrencyState => { if (getEnv().isServer) return { current: 'EUR' }; - const persistedState = localStorage.getItem(LOCAL_STORAGE_KEY); - if (persistedState) { - return JSON.parse(persistedState); - } + const persistedState = persistence.get(CURRENCY.store); + if (persistedState) return JSON.parse(persistedState); - const state = defaultState; - localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(state)); - Cookies.set(COOKIE_STORAGE_KEY, state.current); + const state = { current: defaultBrowserCurrency }; + persist(state); return state; }; export const store = new Store(loadInitialState()); -store.subscribe(({ currentVal }) => { - localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(currentVal)); - Cookies.set(COOKIE_STORAGE_KEY, currentVal.current); -}); +store.subscribe(({ currentVal }) => persist(currentVal)); + +export const resetStore = () => + store.setState({ current: defaultBrowserCurrency }); -export const resetStore = () => store.setState(defaultState); export const setCurrent = (currentCurr: Currency) => store.setState((prev) => ({ ...prev, diff --git a/src/app/stores/language.ts b/src/app/stores/language.ts index a62827c..621c845 100644 --- a/src/app/stores/language.ts +++ b/src/app/stores/language.ts @@ -1,11 +1,14 @@ import { Store } from '@tanstack/store'; -import Cookies from 'js-cookie'; +import * as persistence from '@/lib/isomorphic/persistence'; import { AllowedLanguage } from '@/types/locale'; import { getEnv } from '@/lib/isomorphic/env'; import { getUserDefaultLanguage } from '@/lib/isomorphic/language'; +import { LANGUAGE } from '@/app/constants/stores'; -const LOCAL_STORAGE_KEY = 'language-store'; -const COOKIE_STORAGE_KEY = 'language'; +const persist = persistence.create(LANGUAGE.store, (state) => ({ + data: state.current, + key: LANGUAGE.cookie, +})); type LanguageState = { default: AllowedLanguage; @@ -23,23 +26,16 @@ const loadInitialState = (): LanguageState => { current: 'en-GB', }; - const persistedState = localStorage.getItem(LOCAL_STORAGE_KEY); - if (persistedState) { - return JSON.parse(persistedState); - } + const persistedState = persistence.get(LANGUAGE.store); + if (persistedState) return JSON.parse(persistedState); const state = defaultState(); - localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(state)); - Cookies.set(COOKIE_STORAGE_KEY, state.current); - + persist(state); return state; }; export const store = new Store(loadInitialState()); -store.subscribe(({ currentVal: state }) => { - localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(state)); - Cookies.set(COOKIE_STORAGE_KEY, state.current); -}); +store.subscribe(({ currentVal }) => persist(currentVal)); export const resetStore = () => store.setState(defaultState()); diff --git a/src/app/stores/theme.ts b/src/app/stores/theme.ts index d33c3be..d3ee520 100644 --- a/src/app/stores/theme.ts +++ b/src/app/stores/theme.ts @@ -1,33 +1,31 @@ import { Store } from '@tanstack/store'; -import Cookies from 'js-cookie'; +import { Theme } from '@/types/locale'; +import * as persistence from '@/lib/isomorphic/persistence'; import { getEnv } from '@/lib/isomorphic/env'; import { getSystemThemePreference } from '@/lib/isomorphic/theme'; +import { THEME } from '@/app/constants/stores'; -const LOCAL_STORAGE_KEY = 'color-theme'; -const COOKIE_STORAGE_KEY = 'color-theme'; +const persist = persistence.create(THEME.store, (state) => ({ + data: state.theme, + key: THEME.cookie, +})); type ThemeState = { - theme: 'light' | 'dark'; + theme: Theme; }; const defaultState = (): ThemeState => { - if (getEnv().isServer) return { theme: 'light' }; + if (getEnv().isServer) return { theme: 'dark' }; - const persistedState = localStorage.getItem(LOCAL_STORAGE_KEY); - if (persistedState) { - return JSON.parse(persistedState); - } + const persistedState = persistence.get(THEME.store); + if (persistedState) return JSON.parse(persistedState); const state: ThemeState = { theme: getSystemThemePreference() }; - localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(state)); - Cookies.set(COOKIE_STORAGE_KEY, state.theme); + persist(state); return state; }; export const store = new Store(defaultState()); -store.subscribe(({ currentVal }) => { - localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(currentVal)); - Cookies.set(COOKIE_STORAGE_KEY, currentVal.theme); -}); +store.subscribe(({ currentVal }) => persist(currentVal)); export const reset = () => store.setState(defaultState()); diff --git a/src/lib/isomorphic/api.ts b/src/lib/isomorphic/api.ts index f427c89..1d466c4 100644 --- a/src/lib/isomorphic/api.ts +++ b/src/lib/isomorphic/api.ts @@ -1,11 +1,4 @@ -import { createIsomorphicFn } from '@tanstack/react-start'; -import { getCookie } from '@tanstack/react-start/server'; -import Cookies from 'js-cookie'; +import * as persistence from './persistence'; -export const getCsrfCookie = createIsomorphicFn() - .client(() => Cookies.get('csrf')) - .server(() => getCookie('csrf')); - -export const getRoleCookie = createIsomorphicFn() - .client(() => Cookies.get('role')) - .server(() => getCookie('role')); +export const getCsrfCookie = () => persistence.getCookie('csrf') ?? undefined; +export const getRoleCookie = () => persistence.getCookie('role') ?? undefined; diff --git a/src/lib/isomorphic/cart.ts b/src/lib/isomorphic/cart.ts new file mode 100644 index 0000000..e1b5ea7 --- /dev/null +++ b/src/lib/isomorphic/cart.ts @@ -0,0 +1,8 @@ +import { CART } from '@/app/constants/stores'; +import { CartItem } from '@/app/types/cart-item'; +import { getCookie } from './persistence'; + +const parse = (cookie: string | null): CartItem[] | null => + JSON.parse(cookie ?? 'null'); + +export const getCartCookie = () => parse(getCookie(CART.cookie) ?? null); diff --git a/src/lib/isomorphic/language.ts b/src/lib/isomorphic/language.ts index 98856a8..ebcddab 100644 --- a/src/lib/isomorphic/language.ts +++ b/src/lib/isomorphic/language.ts @@ -1,15 +1,14 @@ import { createIsomorphicFn } from '@tanstack/react-start'; -import { getCookie } from '@tanstack/react-start/server'; -import Cookies from 'js-cookie'; import { AllowedLanguage } from '@/types/locale'; +import { LANGUAGE } from '@/app/constants/stores'; +import { getCookie } from './persistence'; export const getUserDefaultLanguage = createIsomorphicFn() - .client( - () => - (navigator.languages || [navigator.language])[0] as AllowedLanguage, - ) + .client(() => { + const languages = navigator.languages || [navigator.language]; + return languages[0] as AllowedLanguage; + }) .server(() => 'en-GB' as AllowedLanguage); -export const getLanguageCookie = createIsomorphicFn() - .client(() => Cookies.get('language') as AllowedLanguage | undefined) - .server(() => getCookie('language') as AllowedLanguage | undefined); +export const getLanguageCookie = () => + getCookie(LANGUAGE.cookie) as AllowedLanguage | undefined; diff --git a/src/lib/isomorphic/persistence/get.ts b/src/lib/isomorphic/persistence/get.ts new file mode 100644 index 0000000..7ceca14 --- /dev/null +++ b/src/lib/isomorphic/persistence/get.ts @@ -0,0 +1,14 @@ +import { createIsomorphicFn } from '@tanstack/react-start'; +import * as server from '@tanstack/react-start/server'; +import Cookies from 'js-cookie'; + +export const get = createIsomorphicFn() + .client((key: string, from?: 'local' | 'cookies') => + from === 'cookies' ? Cookies.get(key) : localStorage.getItem(key), + ) + .server((key: string, from?: 'local' | 'cookies') => + from === 'cookies' ? server.getCookie(key) : null, + ); + +export const getLocal = (key: string) => get(key, 'local'); +export const getCookie = (key: string) => get(key, 'cookies'); diff --git a/src/lib/isomorphic/persistence/index.ts b/src/lib/isomorphic/persistence/index.ts new file mode 100644 index 0000000..b990690 --- /dev/null +++ b/src/lib/isomorphic/persistence/index.ts @@ -0,0 +1,2 @@ +export * from './get'; +export * from './set'; diff --git a/src/lib/isomorphic/persistence/set.ts b/src/lib/isomorphic/persistence/set.ts new file mode 100644 index 0000000..2bcea48 --- /dev/null +++ b/src/lib/isomorphic/persistence/set.ts @@ -0,0 +1,32 @@ +import { createIsomorphicFn } from '@tanstack/react-start'; +import * as server from '@tanstack/react-start/server'; +import Cookies from 'js-cookie'; + +type Props = { + key: string; + state: TState; + selected?: { key: string; data: unknown }; +}; +const set = createIsomorphicFn() + .client(({ key, state, selected }: Props) => { + localStorage.setItem(key, JSON.stringify(state)); + + if (selected) { + Cookies.set(selected.key ?? key, JSON.stringify(selected.data)); + } + }) + .server(({ key, selected }: Props) => { + if (selected) { + server.setCookie( + selected.key ?? key, + JSON.stringify(selected.data), + ); + } + }); + +export const create = ( + key: string, + selector?: (state: TState) => { data: unknown; key: string }, +) => { + return (state: TState) => set({ key, state, selected: selector?.(state) }); +}; diff --git a/src/lib/isomorphic/theme.ts b/src/lib/isomorphic/theme.ts index e58bdda..aaed5e9 100644 --- a/src/lib/isomorphic/theme.ts +++ b/src/lib/isomorphic/theme.ts @@ -1,6 +1,7 @@ -import { createClientOnlyFn, createIsomorphicFn } from '@tanstack/react-start'; -import { getCookie } from '@tanstack/react-start/server'; -import Cookies from 'js-cookie'; +import { createClientOnlyFn } from '@tanstack/react-start'; +import { Theme } from '@/types/locale'; +import { THEME } from '@/app/constants/stores'; +import { getCookie } from './persistence'; export const getSystemThemePreference = createClientOnlyFn(() => window.matchMedia('(prefers-color-scheme: dark)').matches @@ -8,14 +9,6 @@ export const getSystemThemePreference = createClientOnlyFn(() => : 'light', ); -export const getThemeCookie = createIsomorphicFn() - .client(() => Cookies.get('color-theme') as 'dark' | 'light') - .server(() => getCookie('color-theme') as 'dark' | 'light'); - -export const isLightThemeCookie = createIsomorphicFn() - .client(() => Cookies.get('color-theme') === 'light') - .server(() => getCookie('color-theme') === 'light'); - -export const isDarkThemeCookie = createIsomorphicFn() - .client(() => Cookies.get('color-theme') === 'dark') - .server(() => getCookie('color-theme') === 'dark'); +export const getThemeCookie = () => getCookie(THEME.cookie) as Theme; +export const isLightThemeCookie = () => getThemeCookie() === 'light'; +export const isDarkThemeCookie = () => getThemeCookie() === 'dark'; diff --git a/src/types/locale.ts b/src/types/locale.ts index ac2632e..f51c68f 100644 --- a/src/types/locale.ts +++ b/src/types/locale.ts @@ -15,3 +15,5 @@ export const FLAGS: Record = { 'bg-BG': '/flags/bg.svg', 'en-GB': '/flags/gb.svg', }; + +export type Theme = 'light' | 'dark'; From 99160539a7261aa5b70a7999a4e8485c7a411870 Mon Sep 17 00:00:00 2001 From: Ivan Angelov Date: Mon, 8 Dec 2025 01:48:10 +0200 Subject: [PATCH 10/13] Generally improved various areas - Disabled `` - Included Title head tags per route - Made auth lib utils more DX-friendly - Improved types & code style - Improved UI/UX & styling --- src/app/components/layout/footer/index.tsx | 2 +- .../components/layout/header/menu/content.tsx | 39 ------------------ .../components/layout/header/menu/index.tsx | 40 ++++++++++++++++--- .../layout/header/mobile/navbar.tsx | 2 +- .../components/layout/header/mobile/navs.ts | 5 +-- src/app/components/ui/popover.tsx | 2 +- src/app/hooks/locales/useMoney.ts | 2 +- src/app/pages/public/home/index.tsx | 2 +- src/app/pages/public/product/card.tsx | 17 +++++--- src/app/pages/public/product/index.tsx | 8 ++-- src/app/stores/cart.ts | 10 +---- src/app/utils/page.ts | 2 +- src/client.ts | 4 ++ src/lib/utils/auth.ts | 23 ++++++++++- src/lib/utils/units.ts | 4 +- src/routes/_public/cart.tsx | 1 + src/routes/_public/gallery/$id.tsx | 3 ++ src/routes/_public/gallery/index.tsx | 5 ++- src/types/react.ts | 2 +- 19 files changed, 95 insertions(+), 78 deletions(-) delete mode 100644 src/app/components/layout/header/menu/content.tsx create mode 100644 src/client.ts diff --git a/src/app/components/layout/footer/index.tsx b/src/app/components/layout/footer/index.tsx index 4b12911..ee30365 100644 --- a/src/app/components/layout/footer/index.tsx +++ b/src/app/components/layout/footer/index.tsx @@ -13,7 +13,7 @@ const Footer = () => { const tFooter = useLayoutTranslations('footer'); return ( -