diff --git a/package-lock.json b/package-lock.json index 65734cc..ae3952b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,15 +8,18 @@ "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", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", @@ -879,9 +882,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", @@ -1933,6 +1936,36 @@ } } }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collapsible": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", @@ -2455,6 +2488,100 @@ } } }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.8.tgz", + "integrity": "sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.3", + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.3.tgz", + "integrity": "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", @@ -2519,6 +2646,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..a723d44 100644 --- a/package.json +++ b/package.json @@ -13,15 +13,18 @@ "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", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", diff --git a/src/app/components/cad/editor/hooks/useEditorThreeJS.ts b/src/app/components/cad/editor/hooks/useEditorThreeJS.ts new file mode 100644 index 0000000..614798e --- /dev/null +++ b/src/app/components/cad/editor/hooks/useEditorThreeJS.ts @@ -0,0 +1,55 @@ +import { useRef } from 'react'; +import * as THREE from 'three'; +import { CalculateCad } from '@/types/threejs'; +import { updateMaterial } from '@/lib/cad/material'; +import { boxSize } from '@/lib/cad/three-js'; +import * as editor from '@/app/stores/editor'; +import * as calculate3D from '@/app/utils/calculate-3D'; + +export const useEditorThreeJS = (cadId: string) => { + const refs = { + originalScale: useRef(new THREE.Vector3(0, 0, 0)), + cad: useRef(null), + lastTextures: useRef>(new Map()), + }; + const actions = editor.getActions(cadId); + + const update = { + scene: (cad: THREE.Group) => { + refs.originalScale.current = cad.scale.clone(); + refs.cad.current = cad; + + const size = boxSize(refs.cad.current); + actions.set.size(size); + }, + looks: (texture: string, color?: string) => { + const textures = refs.lastTextures.current; + refs.cad.current?.traverse( + updateMaterial({ texture, color, textures }), + ); + }, + metrics: ({ + volume, + density, + euroPerKg, + scale, + size, + infill, + }: CalculateCad) => { + const volumeMm3 = calculate3D.volumeMm3(volume, scale, size); + + const weight = calculate3D.weightGrams(volumeMm3, infill, density); + actions.set.weight(weight); + + const cost = calculate3D.costEUR(weight, euroPerKg); + actions.set.cost(cost); + + if (refs.cad.current) { + refs.cad.current.scale.copy(refs.originalScale.current); + refs.cad.current.scale.multiplyScalar(scale); + } + }, + }; + + return { update }; +}; diff --git a/src/app/components/cad/editor/index.tsx b/src/app/components/cad/editor/index.tsx new file mode 100644 index 0000000..ab83d45 --- /dev/null +++ b/src/app/components/cad/editor/index.tsx @@ -0,0 +1,49 @@ +import { useQuery } from '@customcads/react-sdk'; +import { AppError } from '@/types/errors'; +import { getCadType } from '@/lib/cad/get-type'; +import { useEditorStore } from '@/app/hooks/stores/useEditorStore'; +import { useCadBlobUrl } from '@/app/hooks/features/cads/useCadBlobUrl'; +import { useTextures } from '@/app/hooks/features/materials/useTextures'; +import Loader from '@/app/components/loading'; +import EditorThreeJS from './threejs'; + +type EditorCadProps = { cadId: string }; +const EditorCad = ({ cadId }: EditorCadProps) => { + const { blobUrl: cadBlobUrl, progress } = useCadBlobUrl(cadId, 'Product'); + const { data: cad } = useQuery(({ cads }) => cads.single({ id: cadId })); + + const materialId = useEditorStore(cadId, (state) => state.materialId); + const textures = useTextures(true); + const texture = textures[materialId]; + + if (Object.keys(textures).length > 0 && !texture) + throw new AppError({ + title: 'Texture Error', + message: 'An error occured during the Rendering process', + tip: 'Reload this page or clear your browser cache.', + }); + + if (!cad || !cadBlobUrl || !texture) { + return ; + } + + return ( +
+ +
+ ); +}; + +export default EditorCad; diff --git a/src/app/components/cad/editor/threejs.tsx b/src/app/components/cad/editor/threejs.tsx new file mode 100644 index 0000000..31e4a4c --- /dev/null +++ b/src/app/components/cad/editor/threejs.tsx @@ -0,0 +1,55 @@ +import { useEffect } from 'react'; +import * as THREE from 'three'; +import { Coordinates } from '@customcads/react-sdk'; +import { type AllowedType } from '@/lib/cad/get-type'; +import { useThreeJS } from '@/hooks/headless/useThreeJS'; +import type { Material } from '@/app/types/material'; +import { useEditorStore } from '@/app/hooks/stores/useEditorStore'; +import { useEditorThreeJS } from './hooks/useEditorThreeJS'; +import Model from '../model'; + +type Cad = { + id: string; + blobUrl: string; + type: AllowedType | null; + coords: { cam: Coordinates; pan: Coordinates }; + volume: number; +}; + +type Props = { cad: Cad; texture: Material }; +const EditorThreeJS = ({ cad, texture }: Props) => { + const { update } = useEditorThreeJS(cad.id); + const store = { + color: useEditorStore(cad.id, (state) => state.color), + size: useEditorStore(cad.id, (state) => state.size), + scale: useEditorStore(cad.id, (state) => state.scale), + infill: useEditorStore(cad.id, (state) => state.infill), + }; + + const threejs = useThreeJS( + { url: cad.blobUrl, type: cad.type, coords: cad.coords }, + (threejs) => { + update.scene(threejs); + threejs.traverse((child) => { + if (child instanceof THREE.Mesh) { + child.material.map = undefined; + } + }); + + update.looks(texture.blobUrl, store.color); + update.metrics({ ...cad, ...texture, ...store }); + }, + ); + + useEffect(() => { + update.looks(texture.blobUrl, store.color); + }, [texture, store.color]); + + useEffect(() => { + update.metrics({ ...cad, ...texture, ...store }); + }, [cad.volume, texture.density, store.size, store.scale, store.infill]); + + return ; +}; + +export default EditorThreeJS; diff --git a/src/app/components/cad/gallery/index.tsx b/src/app/components/cad/gallery/index.tsx new file mode 100644 index 0000000..73060cf --- /dev/null +++ b/src/app/components/cad/gallery/index.tsx @@ -0,0 +1,29 @@ +import { useQuery } from '@customcads/react-sdk'; +import { getCadType } from '@/lib/cad/get-type'; +import { useCadBlobUrl } from '@/app/hooks/features/cads/useCadBlobUrl'; +import Loader from '@/app/components/loading'; +import GalleryThreeJS from './threejs'; + +const GalleryCad = ({ cadId }: { cadId: string }) => { + const { data: cad } = useQuery(({ cads }) => cads.single({ id: cadId })); + const { blobUrl, progress } = useCadBlobUrl(cadId, 'Product'); + + return ( +
+ {!cad || !blobUrl ? ( + + ) : ( + + )} +
+ ); +}; + +export default GalleryCad; diff --git a/src/app/components/cad/gallery/threejs.tsx b/src/app/components/cad/gallery/threejs.tsx new file mode 100644 index 0000000..3c2a6b8 --- /dev/null +++ b/src/app/components/cad/gallery/threejs.tsx @@ -0,0 +1,20 @@ +import { Coordinates } from '@customcads/react-sdk'; +import { type AllowedType } from '@/lib/cad/get-type'; +import { useThreeJS } from '@/hooks/headless/useThreeJS'; +import Model from '../model'; + +type Props = { + file: { url: string; type: AllowedType | null }; + cam: Coordinates; + pan: Coordinates; +}; +const GalleryThreeJS = ({ file, cam, pan }: Props) => { + const threejs = useThreeJS({ + url: file.url, + type: file.type, + coords: { cam, pan }, + }); + return ; +}; + +export default GalleryThreeJS; diff --git a/src/app/components/cad/index.tsx b/src/app/components/cad/index.tsx new file mode 100644 index 0000000..d271ddc --- /dev/null +++ b/src/app/components/cad/index.tsx @@ -0,0 +1,15 @@ +import GalleryCad from './gallery'; +import EditorCad from './editor'; + +type Props = { cadId: string } & ({ type: 'gallery' } | { type: 'editor' }); +const Cad = (props: Props) => { + switch (props.type) { + case 'gallery': + return ; + case 'editor': + return ; + default: + } +}; + +export default Cad; diff --git a/src/app/components/cad/model.tsx b/src/app/components/cad/model.tsx new file mode 100644 index 0000000..6b11f9e --- /dev/null +++ b/src/app/components/cad/model.tsx @@ -0,0 +1,16 @@ +import { Ref } from 'react'; +import Loader from '../loading'; + +type ModelProps = { + threejs: { + ref: Ref; + progress: number; + }; +}; + +const Model = ({ threejs: { ref, progress } }: ModelProps) => [ + progress < 1 && , +
, +]; + +export default Model; diff --git a/src/app/components/fields/radio.tsx b/src/app/components/fields/radio.tsx new file mode 100644 index 0000000..7ebf8e2 --- /dev/null +++ b/src/app/components/fields/radio.tsx @@ -0,0 +1,14 @@ +import { RadioGroupItem } from '@/app/components/ui/radio-group'; +import { Label } from '@/app/components/ui/label'; + +type Props = { value: string; label?: string }; +const RadioField = ({ value, label }: Props) => ( +
+ + +
+); + +export default RadioField; diff --git a/src/app/components/fields/range.tsx b/src/app/components/fields/range.tsx new file mode 100644 index 0000000..93194f0 --- /dev/null +++ b/src/app/components/fields/range.tsx @@ -0,0 +1,59 @@ +import { useRef } from 'react'; +import { RangerOptions, useRanger } from '@tanstack/react-ranger'; +import { Button } from '@/app/components/ui/button'; + +type RangerElement = HTMLDivElement; + +type BaseProps = Omit, 'getRangerElement'>; +type StepProps = { stepSize: number } | { steps: number[] }; + +type Props = BaseProps & StepProps; +const RangeField = (props: Props) => { + const rangerRef = useRef(null); + const { + handles, + options: { min, max }, + getPercentageForValue: percentage, + } = useRanger({ + ...props, + getRangerElement: () => rangerRef.current, + }); + + return ( +
+ {handles().map((handle, i) => ( +
+ ); +}; + +export default RangeField; 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 ( -
+
  • diff --git a/src/app/components/layout/footer/link.tsx b/src/app/components/layout/footer/link.tsx index 884ad90..d742452 100644 --- a/src/app/components/layout/footer/link.tsx +++ b/src/app/components/layout/footer/link.tsx @@ -4,7 +4,7 @@ type FooterLinkProps = LinkProps & { text: string }; const FooterLink = ({ text, ...props }: FooterLinkProps) => { return ( - + {text} diff --git a/src/app/components/layout/header/cart/index.tsx b/src/app/components/layout/header/cart/index.tsx new file mode 100644 index 0000000..662d146 --- /dev/null +++ b/src/app/components/layout/header/cart/index.tsx @@ -0,0 +1,28 @@ +import { ShoppingCart } from 'lucide-react'; +import { useCartStore } from '@/app/hooks/stores/useCartStore'; +import { useAuthStore } from '@/app/hooks/stores/useAuthStore'; +import CustomIcon from '@/app/components/icon'; +import { Button } from '@/app/components/ui/button'; + +const CartIndicator = ({ text }: { text?: string }) => { + const { is } = useAuthStore(); + const { items } = useCartStore(); + + if (!is.guest && !is.customer) return; + + return ( +
    + + {items?.length ? ( + + ) : null} +
    + ); +}; + +export default CartIndicator; diff --git a/src/app/components/layout/header/menu/content.tsx b/src/app/components/layout/header/menu/content.tsx deleted file mode 100644 index eda8a69..0000000 --- a/src/app/components/layout/header/menu/content.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Link } from '@tanstack/react-router'; -import { useLayoutTranslations } from '@/app/hooks/locales/translations/components'; -import { - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, -} from '@/app/components/ui/dropdown-menu'; -import { useItems } from './hooks/useItems'; - -const Content = () => { - const tHeader = useLayoutTranslations('header'); - - const { titleKey, items } = useItems(); - if (!items.length) return null; - - return ( - - {tHeader(titleKey)} - - {items.map((group, i) => ( - <> - - {group.map(({ link, textKey }) => ( - - - {tHeader(textKey)} - - - ))} - - {i !== items.length - 1 && } - - ))} - - ); -}; -export default Content; diff --git a/src/app/components/layout/header/menu/index.tsx b/src/app/components/layout/header/menu/index.tsx index c8e6857..f03c706 100644 --- a/src/app/components/layout/header/menu/index.tsx +++ b/src/app/components/layout/header/menu/index.tsx @@ -1,21 +1,51 @@ +import { Link } from '@tanstack/react-router'; import { MenuIcon } from 'lucide-react'; +import { useLayoutTranslations } from '@/app/hooks/locales/translations/components'; import { DropdownMenu, DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, } from '@/app/components/ui/dropdown-menu'; -import Content from './content'; +import { useItems } from './hooks/useItems'; + +const Content = () => { + const tHeader = useLayoutTranslations('header'); + + const { titleKey, items } = useItems(); + if (!items.length) return null; -const Sidebar = () => { return (
    - + + {tHeader(titleKey)} + + {items.map((group, i) => ( + <> + + {group.map(({ link, textKey }) => ( + + + {tHeader(textKey)} + + + ))} + + {i !== items.length - 1 && ( + + )} + + ))} +
    ); }; - -export default Sidebar; +export default Content; diff --git a/src/app/components/layout/header/mobile/navbar.tsx b/src/app/components/layout/header/mobile/navbar.tsx index f21e341..83ff140 100644 --- a/src/app/components/layout/header/mobile/navbar.tsx +++ b/src/app/components/layout/header/mobile/navbar.tsx @@ -1,12 +1,13 @@ import { Link } from '@tanstack/react-router'; -import { ShoppingCart, UserCircle } from 'lucide-react'; +import { UserCircle } from 'lucide-react'; import * as auth from '@/lib/utils/auth'; import { TITLE } from '@/app/constants/global'; import CustomIcon from '@/app/components/icon'; import NotificationsTab from '@/app/components/layout/header/notifications'; +import CartIndicator from '@/app/components/layout/header/cart'; import { SidebarInset, SidebarTrigger } from '@/app/components/ui/sidebar'; -const Header = ({ is }: { is: ReturnType }) => ( +const Header = ({ is }: { is: auth.Returns }) => (
      @@ -19,7 +20,7 @@ const Header = ({ is }: { is: ReturnType }) => (
    • - +
    diff --git a/src/app/components/layout/header/mobile/navs.ts b/src/app/components/layout/header/mobile/navs.ts index 25a089b..08d8002 100644 --- a/src/app/components/layout/header/mobile/navs.ts +++ b/src/app/components/layout/header/mobile/navs.ts @@ -2,11 +2,10 @@ import { Hammer, Home, Store } from 'lucide-react'; import * as auth from '@/lib/utils/auth'; import { SidebarProps } from './sidebar'; -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) @@ -135,6 +134,6 @@ export default { }, } satisfies { main: SidebarProps['main']; - getCollections: (is: Is) => SidebarProps['collections']; - getWorkspaces: (is: Is) => SidebarProps['workspaces']; + getCollections: (is: auth.Returns) => SidebarProps['collections']; + getWorkspaces: (is: auth.Returns) => SidebarProps['workspaces']; }; diff --git a/src/app/components/layout/header/navbar.tsx b/src/app/components/layout/header/navbar.tsx index e3c4d3c..732cb82 100644 --- a/src/app/components/layout/header/navbar.tsx +++ b/src/app/components/layout/header/navbar.tsx @@ -1,5 +1,5 @@ import { Link } from '@tanstack/react-router'; -import { Home, Settings2, ShoppingCart, Store, UserCircle } from 'lucide-react'; +import { Home, Settings2, Store, UserCircle } from 'lucide-react'; import { useLayoutTranslations } from '@/app/hooks/locales/translations/components'; import { useAuthStore } from '@/app/hooks/stores/useAuthStore'; import { TITLE } from '@/app/constants/global'; @@ -7,6 +7,7 @@ import CustomIcon from '@/app/components/icon'; import Menu from './menu'; import NotificationsTab from './notifications'; import AccountMenu from './account'; +import CartIndicator from './cart'; const Navbar = () => { const { is } = useAuthStore(); @@ -18,12 +19,12 @@ const Navbar = () => {
  • - +
  • { - useThemeStore({ render: true }); + useThemeSync(); useAuthSync(); useLanguagesSync(); + useCartSync(); return (
    diff --git a/src/app/components/loading/index.tsx b/src/app/components/loading/index.tsx new file mode 100644 index 0000000..8cc2eb6 --- /dev/null +++ b/src/app/components/loading/index.tsx @@ -0,0 +1,16 @@ +import { Progress } from '@/app/components/ui/progress'; +import { Spinner } from '@/app/components/ui/spinner'; + +type LoaderProps = + | { isCad: true; progress: number } + | { isCad?: undefined | false }; +const Loader = (props: LoaderProps) => { + if (!props.isCad) { + return ; + } + + if (props.progress === undefined) return; + return ; +}; + +export default Loader; diff --git a/src/app/components/search/categories/compobox.tsx b/src/app/components/search/categories/compobox.tsx new file mode 100644 index 0000000..94ab684 --- /dev/null +++ b/src/app/components/search/categories/compobox.tsx @@ -0,0 +1,77 @@ +'use client'; +import { useState } from 'react'; +import { CheckIcon } from 'lucide-react'; +import { cn } from '@/lib/utils/tailwindcss'; +import Loader from '@/app/components/loading'; +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 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..dabdbc4 --- /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..48a1fc7 --- /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:min-w-75 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..00ba78c --- /dev/null +++ b/src/app/components/search/sortings/compobox.tsx @@ -0,0 +1,77 @@ +'use client'; +import { useState } from 'react'; +import { CheckIcon } from 'lucide-react'; +import { cn } from '@/lib/utils/tailwindcss'; +import Loader from '@/app/components/loading'; +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..f5d5c3a --- /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/checkbox.tsx b/src/app/components/ui/checkbox.tsx new file mode 100644 index 0000000..2d9013b --- /dev/null +++ b/src/app/components/ui/checkbox.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; +import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; +import { CheckIcon } from 'lucide-react'; +import { cn } from '@/lib/utils/tailwindcss'; + +const Checkbox = ({ + className, + ...props +}: React.ComponentProps) => ( + + + + + +); + +export { Checkbox }; 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 ( +
    +
    + {tDevtools('currency')} + +
); diff --git a/src/app/integrations/user-preferences/language/index.tsx b/src/app/integrations/user-preferences/language/index.tsx index dca9180..9891630 100644 --- a/src/app/integrations/user-preferences/language/index.tsx +++ b/src/app/integrations/user-preferences/language/index.tsx @@ -1,7 +1,9 @@ import { Globe } from 'lucide-react'; import { ALLOWED_LANGUAGES, FLAGS } from '@/types/locale'; -import { useLayoutTranslations } from '@/app/hooks/locales/translations/components'; -import { useLocalesTranslations } from '@/app/hooks/locales/translations/common'; +import { + useDevtoolsTranslations, + useLocalesTranslations, +} from '@/app/hooks/locales/translations/common'; import { useLanguageStore } from '@/app/hooks/stores/useLanguageStore'; import * as languageStore from '@/app/stores/language'; import CustomIcon from '@/app/components/icon'; @@ -9,7 +11,7 @@ import Compobox from './compobox'; const LanguageMenu = () => { const { current } = useLanguageStore(); - const tHeader = useLayoutTranslations('header'); + const tDevtools = useDevtoolsTranslations(); const tLocales = useLocalesTranslations(); return ( @@ -20,8 +22,14 @@ const LanguageMenu = () => { value: lang, flag: FLAGS[lang], }))} - placeholder={tHeader('language-placeholder')} - trigger={} + placeholder={tDevtools('language-placeholder')} + trigger={ + + } onSelect={languageStore.setCurrent} /> ); diff --git a/src/app/locales/bg-BG/common/empty.ts b/src/app/locales/bg-BG/common/empty.ts new file mode 100644 index 0000000..b7709ee --- /dev/null +++ b/src/app/locales/bg-BG/common/empty.ts @@ -0,0 +1,5 @@ +import { Empty } from '../../types/common'; + +export default { + products: 'Няма намерени Продукти.', +} satisfies Empty; diff --git a/src/app/locales/bg-BG/common/metrics.ts b/src/app/locales/bg-BG/common/metrics.ts new file mode 100644 index 0000000..1149ef2 --- /dev/null +++ b/src/app/locales/bg-BG/common/metrics.ts @@ -0,0 +1,8 @@ +import { Metrics } from '@/app/locales/types/common'; + +export default { + width: 'Широчина', + length: 'Дължина', + height: 'Височина', + volume: 'Обем', +} satisfies Metrics; diff --git a/src/app/locales/bg-BG/components/form/placeholders.ts b/src/app/locales/bg-BG/components/form/placeholders.ts new file mode 100644 index 0000000..4fcd305 --- /dev/null +++ b/src/app/locales/bg-BG/components/form/placeholders.ts @@ -0,0 +1,7 @@ +import { Placeholders } from '@/app/locales/types/components/form'; + +export default { + username: 'Иван_Георгиев', + password: '54Р0Л4', + 'search-products': 'Потърси Продукт по Името му', +} satisfies Placeholders; diff --git a/src/app/locales/bg-BG/components/layout/header.ts b/src/app/locales/bg-BG/components/layout/header.ts index d4988e4..227c161 100644 --- a/src/app/locales/bg-BG/components/layout/header.ts +++ b/src/app/locales/bg-BG/components/layout/header.ts @@ -37,6 +37,4 @@ export default { login: 'Влез', register: 'Регистрирай се', logout: 'Излез', - language: 'Език', - 'language-placeholder': 'Потърсете език...', } satisfies Header; 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/editor.ts b/src/app/locales/bg-BG/pages/public/gallery/editor.ts new file mode 100644 index 0000000..10365f9 --- /dev/null +++ b/src/app/locales/bg-BG/pages/public/gallery/editor.ts @@ -0,0 +1,20 @@ +import { Editor } from '@/app/locales/types/pages/public/gallery'; + +export default { + weight: 'Тежест на Модел', + cost: 'Разход за Принтиране', + distance: 'Разстояние', + mass: 'Маса', + composition: 'Състав', + color: 'Цвят', + 'clear-color': 'Нулирай цвят', + material: 'Материал', + infill: 'Пълнеж', + 'infill-description': + 'Нивото на запълване на вътрешното пространство на 3D Модел (определя Тежестта му)', + unrecommended: 'Непрепоръчително', + dimensions: 'Размери', + scale: 'Мащаб', + reset: 'Нулирай', + next: 'Напред', +} satisfies Editor; diff --git a/src/app/locales/bg-BG/pages/public/gallery/product.ts b/src/app/locales/bg-BG/pages/public/gallery/product.ts new file mode 100644 index 0000000..9f0b2b0 --- /dev/null +++ b/src/app/locales/bg-BG/pages/public/gallery/product.ts @@ -0,0 +1,19 @@ +import { Product } from '@/app/locales/types/pages/public/gallery'; + +export default { + price: 'Цена', + category: 'Категория', + creator: 'От', + like: 'Харесай този Продукт', + 'like-short': 'Харесай', + liked: 'Отхаресай Продукт', + 'liked-short': 'Отхаресай', + add: 'Добави към Количката', + '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/common/devtools.ts b/src/app/locales/en-GB/common/devtools.ts index ecaa3e4..b652dd1 100644 --- a/src/app/locales/en-GB/common/devtools.ts +++ b/src/app/locales/en-GB/common/devtools.ts @@ -3,4 +3,7 @@ import { Devtools } from '../../types/common'; export default { theme: 'Theme:', language: 'Language:', + 'language-placeholder': 'Search language...', + currency: 'Currency:', + 'currency-placeholder': 'Search currency...', } satisfies Devtools; diff --git a/src/app/locales/en-GB/common/empty.ts b/src/app/locales/en-GB/common/empty.ts new file mode 100644 index 0000000..a59d8d4 --- /dev/null +++ b/src/app/locales/en-GB/common/empty.ts @@ -0,0 +1,5 @@ +import { Empty } from '../../types/common'; + +export default { + products: 'No Products found.', +} satisfies Empty; diff --git a/src/app/locales/en-GB/common/metrics.ts b/src/app/locales/en-GB/common/metrics.ts new file mode 100644 index 0000000..c8093e1 --- /dev/null +++ b/src/app/locales/en-GB/common/metrics.ts @@ -0,0 +1,8 @@ +import { Metrics } from '@/app/locales/types/common'; + +export default { + width: 'Width', + length: 'Length', + height: 'Height', + volume: 'Volume', +} satisfies Metrics; diff --git a/src/app/locales/en-GB/components/form/placeholders.ts b/src/app/locales/en-GB/components/form/placeholders.ts index 0254872..ebab446 100644 --- a/src/app/locales/en-GB/components/form/placeholders.ts +++ b/src/app/locales/en-GB/components/form/placeholders.ts @@ -3,4 +3,5 @@ import { Placeholders } from '@/app/locales/types/components/form'; export default { username: 'John_Doe', password: 'P455W0RD', + 'search-products': 'Search a Product by its Name', } satisfies Placeholders; diff --git a/src/app/locales/en-GB/components/layout/header.ts b/src/app/locales/en-GB/components/layout/header.ts index 106d3f5..5d1f968 100644 --- a/src/app/locales/en-GB/components/layout/header.ts +++ b/src/app/locales/en-GB/components/layout/header.ts @@ -37,6 +37,4 @@ export default { login: 'Login', register: 'Register', logout: 'Logout', - language: 'Language', - 'language-placeholder': 'Search language...', } satisfies Header; 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/editor.ts b/src/app/locales/en-GB/pages/public/gallery/editor.ts new file mode 100644 index 0000000..6faa649 --- /dev/null +++ b/src/app/locales/en-GB/pages/public/gallery/editor.ts @@ -0,0 +1,20 @@ +import { Editor } from '@/app/locales/types/pages/public/gallery'; + +export default { + weight: 'Model Weight', + cost: 'Print Cost', + distance: 'Distance', + mass: 'Mass', + composition: 'Composition', + color: 'Color', + 'clear-color': 'Revert color', + material: 'Material', + infill: 'Infill', + 'infill-description': + 'The degree of the filling inside a 3D Model (determines its Weight)', + unrecommended: 'Not Recommended', + dimensions: 'Dimensions', + scale: 'Scale', + reset: 'Reset', + next: 'Next', +} satisfies Editor; diff --git a/src/app/locales/en-GB/pages/public/gallery/product.ts b/src/app/locales/en-GB/pages/public/gallery/product.ts new file mode 100644 index 0000000..6878e74 --- /dev/null +++ b/src/app/locales/en-GB/pages/public/gallery/product.ts @@ -0,0 +1,18 @@ +import { Product } from '@/app/locales/types/pages/public/gallery'; + +export default { + price: 'Price', + category: 'Category', + creator: 'By', + like: 'Like this Product', + 'like-short': 'Like', + liked: 'Unlike this Product', + 'liked-short': 'Unlike', + add: 'Add to Cart', + '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/common.ts b/src/app/locales/types/common.ts index 9ef785f..6f988f7 100644 --- a/src/app/locales/types/common.ts +++ b/src/app/locales/types/common.ts @@ -2,9 +2,23 @@ import { Language } from '@/types/locale'; export type Locales = Record; +export type Empty = { + products: string; +}; + export type Devtools = { theme: string; language: string; + 'language-placeholder': string; + currency: string; + 'currency-placeholder': string; +}; + +export type Metrics = { + width: string; + height: string; + length: string; + volume: string; }; export type Errors = { diff --git a/src/app/locales/types/components/form.ts b/src/app/locales/types/components/form.ts index 3da418f..566c6a2 100644 --- a/src/app/locales/types/components/form.ts +++ b/src/app/locales/types/components/form.ts @@ -11,6 +11,7 @@ export type Labels = { }; export type Placeholders = { + 'search-products': string; username: string; password: string; }; diff --git a/src/app/locales/types/components/layout.ts b/src/app/locales/types/components/layout.ts index bc2afa6..5269f72 100644 --- a/src/app/locales/types/components/layout.ts +++ b/src/app/locales/types/components/layout.ts @@ -51,6 +51,4 @@ export type Header = { login: string; register: string; logout: string; - language: string; - 'language-placeholder': string; }; diff --git a/src/app/locales/types/index.ts b/src/app/locales/types/index.ts index 885f98f..58a1fe3 100644 --- a/src/app/locales/types/index.ts +++ b/src/app/locales/types/index.ts @@ -4,6 +4,8 @@ import * as common from './common'; export type CommonTranslations = { 'common.locales': common.Locales; + 'common.metrics': common.Metrics; + 'common.empty': common.Empty; 'common.devtools': common.Devtools; 'common.errors': common.Errors; }; diff --git a/src/app/locales/types/pages/public/gallery.ts b/src/app/locales/types/pages/public/gallery.ts new file mode 100644 index 0000000..a3d2843 --- /dev/null +++ b/src/app/locales/types/pages/public/gallery.ts @@ -0,0 +1,47 @@ +export type Product = { + price: string; + category: string; + creator: string; + like: string; + 'like-short': string; + liked: string; + 'liked-short': string; + add: string; + '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; + view: string; + customize: string; + delivery: string; + undeliverable: string; + 'product-price': string; + 'print-cost': string; + 'total-sum': string; + buy: string; + 'no-items': string; +}; + +export type Editor = { + weight: string; + cost: string; + mass: string; + distance: string; + composition: string; + color: string; + 'clear-color': string; + material: string; + infill: string; + 'infill-description': string; + unrecommended: string; + dimensions: string; + scale: string; + reset: string; + next: string; +}; diff --git a/src/app/locales/types/pages/public/index.ts b/src/app/locales/types/pages/public/index.ts index 79faad1..68d1ba5 100644 --- a/src/app/locales/types/pages/public/index.ts +++ b/src/app/locales/types/pages/public/index.ts @@ -1,5 +1,9 @@ import * as info from './info'; +import * as gallery from './gallery'; export type Translations = { 'pages.public.info.home': info.Home; + 'pages.public.gallery.product': gallery.Product; + 'pages.public.gallery.cart': gallery.Cart; + 'pages.public.gallery.editor': gallery.Editor; }; diff --git a/src/app/pages/guest/signin/login/hooks/useForm.ts b/src/app/pages/guest/signin/login/hooks/useForm.ts index c350f84..d9e6df5 100644 --- a/src/app/pages/guest/signin/login/hooks/useForm.ts +++ b/src/app/pages/guest/signin/login/hooks/useForm.ts @@ -1,9 +1,11 @@ import { useNavigate } from '@tanstack/react-router'; import { useForm as useTanStackForm } from '@tanstack/react-form'; import { useMutation, useQuery } from '@customcads/react-sdk'; +import { AppError } from '@/types/errors'; import * as form from '@/lib/utils/form'; import { useAuthStore } from '@/app/hooks/stores/useAuthStore'; import { useNotificationQueryData } from '@/app/hooks/features/notifications/useNotificationQueryData'; +import { useCartTransfer } from '@/app/hooks/features/carts/useCartTransfer'; import { useFormTranslations } from '@/app/hooks/locales/translations/components'; import { useForceLocaleRefresh } from '@/app/hooks/locales/useForceLocaleRefresh'; import { schema } from '@/app/validators/login'; @@ -27,9 +29,11 @@ export const useForm = () => { ({ identity }) => identity.authz, false, ); + const notifications = useNotificationQueryData({ params: { all: { limit: 10 } }, }); + useCartTransfer(); const tErrors = useFormTranslations('errors'); const tLabels = useFormTranslations('labels'); @@ -40,9 +44,14 @@ export const useForm = () => { onSubmit: async ({ value }) => { await login(value); const { data: role } = await authz(); - if (role) { - authStore.login(role); - } + if (!role) + throw new AppError({ + title: 'Login Error', + message: 'An error occured during the Login process', + tip: 'Reload this page and try logging in again.', + }); + + authStore.login(role); notifications.invalidate(); }, validators: { 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/pages/public/editor/buttons/index.ts b/src/app/pages/public/editor/buttons/index.ts new file mode 100644 index 0000000..04d6121 --- /dev/null +++ b/src/app/pages/public/editor/buttons/index.ts @@ -0,0 +1,2 @@ +export { default as ResetButton } from './reset'; +export { default as NextButton } from './next'; diff --git a/src/app/pages/public/editor/buttons/next.tsx b/src/app/pages/public/editor/buttons/next.tsx new file mode 100644 index 0000000..595b7d9 --- /dev/null +++ b/src/app/pages/public/editor/buttons/next.tsx @@ -0,0 +1,29 @@ +import { type EditCustomziationRequest } from '@customcads/react-sdk'; +import { useGalleryTranslations } from '@/app/hooks/locales/translations/pages/public'; +import { useEditorStore } from '@/app/hooks/stores/useEditorStore'; +import { GeneralButton } from '../general'; + +type SaveRequest = Pick< + EditCustomziationRequest, + 'materialId' | 'color' | 'scale' | 'infill' +>; + +type Props = { + cadId: string; + save: (body: SaveRequest) => void; +}; +const NextButton = ({ cadId, save }: Props) => { + const tEditor = useGalleryTranslations('editor'); + + const store = { + materialId: useEditorStore(cadId, (state) => state.materialId), + color: useEditorStore(cadId, (state) => state.color), + scale: useEditorStore(cadId, (state) => state.scale), + infill: useEditorStore(cadId, (state) => state.infill), + }; + const next = () => save(store); + + return {tEditor('next')}; +}; + +export default NextButton; diff --git a/src/app/pages/public/editor/buttons/reset.tsx b/src/app/pages/public/editor/buttons/reset.tsx new file mode 100644 index 0000000..f8f599f --- /dev/null +++ b/src/app/pages/public/editor/buttons/reset.tsx @@ -0,0 +1,10 @@ +import { useGalleryTranslations } from '@/app/hooks/locales/translations/pages/public'; +import { GeneralButton } from '../general'; + +const ResetButton = ({ reset }: { reset: VoidFunction }) => { + const tEditor = useGalleryTranslations('editor'); + + return {tEditor('reset')}; +}; + +export default ResetButton; diff --git a/src/app/pages/public/editor/general/button.tsx b/src/app/pages/public/editor/general/button.tsx new file mode 100644 index 0000000..49d989a --- /dev/null +++ b/src/app/pages/public/editor/general/button.tsx @@ -0,0 +1,15 @@ +import { Children } from '@/types/react'; +import { Button } from '@/app/components/ui/button'; + +type Props = { onClick?: VoidFunction } & Children; +const GeneralButton = ({ children, onClick }: Props) => ( + +); + +export default GeneralButton; diff --git a/src/app/pages/public/editor/general/index.ts b/src/app/pages/public/editor/general/index.ts new file mode 100644 index 0000000..e6e3d6d --- /dev/null +++ b/src/app/pages/public/editor/general/index.ts @@ -0,0 +1,3 @@ +export { default as GeneralButton } from './button'; +export { default as GeneralSection } from './section'; +export { default as GeneralTitle } from './title'; diff --git a/src/app/pages/public/editor/general/section.tsx b/src/app/pages/public/editor/general/section.tsx new file mode 100644 index 0000000..1a16933 --- /dev/null +++ b/src/app/pages/public/editor/general/section.tsx @@ -0,0 +1,11 @@ +import { Children } from '@/types/react'; +import { cn } from '@/lib/utils/tailwindcss'; + +type Props = { className?: string } & Children; +const GeneralSection = ({ children, className }: Props) => ( +
+ {children} +
+); + +export default GeneralSection; diff --git a/src/app/pages/public/editor/general/title.tsx b/src/app/pages/public/editor/general/title.tsx new file mode 100644 index 0000000..0e4ddcb --- /dev/null +++ b/src/app/pages/public/editor/general/title.tsx @@ -0,0 +1,8 @@ +import { Children } from '@/types/react'; + +type Props = { level?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' } & Children; +const GeneralTitle = ({ level: Level = 'h2', children }: Props) => ( + {children} +); + +export default GeneralTitle; diff --git a/src/app/pages/public/editor/hooks/index.ts b/src/app/pages/public/editor/hooks/index.ts new file mode 100644 index 0000000..acfd1fc --- /dev/null +++ b/src/app/pages/public/editor/hooks/index.ts @@ -0,0 +1 @@ +export * from './useRadioGroups'; diff --git a/src/app/pages/public/editor/hooks/useRadioGroups.tsx b/src/app/pages/public/editor/hooks/useRadioGroups.tsx new file mode 100644 index 0000000..6196cd2 --- /dev/null +++ b/src/app/pages/public/editor/hooks/useRadioGroups.tsx @@ -0,0 +1,42 @@ +import React, { useState } from 'react'; +import type { Distance, Mass } from '@/types/units'; +import RadioField from '@/app/components/fields/radio'; +import { RadioGroup } from '@/app/components/ui/radio-group'; + +export const useRadioGroups = () => { + const [distance, setDistance] = useState('mm'); + const [mass, setMass] = useState('g'); + + return { + distance, + mass, + ui: { + distance: (metrics: Distance[]) => ({ + Wrapper: (props: React.ComponentProps) => ( + setDistance(x as Distance)} + {...props} + /> + ), + items: metrics.map((x) => ( + + )), + }), + mass: (metrics: Mass[]) => ({ + Wrapper: (props: React.ComponentProps) => ( + setMass(x as Mass)} + {...props} + /> + ), + items: metrics.map((x) => ( + + )), + }), + }, + }; +}; diff --git a/src/app/pages/public/editor/index.tsx b/src/app/pages/public/editor/index.tsx new file mode 100644 index 0000000..3281c75 --- /dev/null +++ b/src/app/pages/public/editor/index.tsx @@ -0,0 +1,100 @@ +import { getRouteApi } from '@tanstack/react-router'; +import { useQuery, type EditCustomziationRequest } from '@customcads/react-sdk'; +import { cn } from '@/lib/utils/tailwindcss'; +import * as editor from '@/app/stores/editor'; +import { useEditorStore } from '@/app/hooks/stores/useEditorStore'; +import { useCartItemEditor } from '@/app/hooks/features/carts/useCartItemEditor'; +import { useGalleryTranslations } from '@/app/hooks/locales/translations/pages/public'; +import Cad from '@/app/components/cad'; +import Loader from '@/app/components/loading'; +import * as page from '@/app/utils/page'; +import * as calculate3D from '@/app/utils/calculate-3D'; +import { useRadioGroups } from './hooks'; +import Side from './side'; +import { ResetButton, NextButton } from './buttons'; +import { GeneralSection, GeneralTitle } from './general'; +import { DimensionsInfo, PrintInfo } from './info'; +import { Color, Materials } from './looks'; +import { InfillRange, ScaleRange } from './range'; +import { DistanceRadio, MassRadio } from './radio'; + +const Route = getRouteApi('/_public/editor/$id'); + +const Editor = () => { + const navigate = Route.useNavigate(); + const { cadId, productId, cadVolume } = Route.useLoaderData(); + + const { data: product } = useQuery(({ products }) => + products.gallery.single({ id: productId }), + ); + const { customization, save } = useCartItemEditor(productId); + + const scale = useEditorStore(cadId, (state) => state.scale); + const size = useEditorStore(cadId, (state) => state.size); + + const radio = useRadioGroups(); + const tEditor = useGalleryTranslations('editor'); + + if (!product || !customization) return ; + + const actions = editor.getActions(cadId); + const volume = calculate3D.volumeMm3(cadVolume, scale, size); + + const handleNext = ( + store: Omit, + ) => { + const { id } = customization; + return save({ id, volume, ...store }, () => navigate({ to: '/cart' })); + }; + + return ( +
+
+ +
+ +
+
+ + +
+
+ + + {tEditor('composition')} + + + + + + {tEditor('dimensions')} + + + + +
+ + +
+
+
+
+ ); +}; + +export default Editor; diff --git a/src/app/pages/public/editor/info/dimensions.tsx b/src/app/pages/public/editor/info/dimensions.tsx new file mode 100644 index 0000000..68b5368 --- /dev/null +++ b/src/app/pages/public/editor/info/dimensions.tsx @@ -0,0 +1,33 @@ +import type { Distance } from '@/types/units'; +import * as units from '@/lib/utils/units'; +import { useEditorStore } from '@/app/hooks/stores/useEditorStore'; +import { useMetricsTranslation } from '@/app/hooks/locales/translations/common'; +import * as calculate3D from '@/app/utils/calculate-3D'; + +type Props = { id: string; volume: number; distance: Distance }; +const DimensionsInfo = ({ id, volume, distance }: Props) => { + const tMetrics = useMetricsTranslation(); + const scale = useEditorStore(id, (state) => state.scale); + + const size = useEditorStore(id, (state) => state.size); + const ratio = calculate3D.baseRatio(size); + + return ( +

+ + {tMetrics('width')}: {units.size(ratio.x * scale, distance)} + + + {tMetrics('height')}: {units.size(ratio.y * scale, distance)} + + + {tMetrics('length')}: {units.size(ratio.z * scale, distance)} + + + {tMetrics('volume')}: {units.volume(volume, distance)} + +

+ ); +}; + +export default DimensionsInfo; diff --git a/src/app/pages/public/editor/info/index.ts b/src/app/pages/public/editor/info/index.ts new file mode 100644 index 0000000..04e46c1 --- /dev/null +++ b/src/app/pages/public/editor/info/index.ts @@ -0,0 +1,2 @@ +export { default as DimensionsInfo } from './dimensions'; +export { default as PrintInfo } from './print'; diff --git a/src/app/pages/public/editor/info/print.tsx b/src/app/pages/public/editor/info/print.tsx new file mode 100644 index 0000000..dc86328 --- /dev/null +++ b/src/app/pages/public/editor/info/print.tsx @@ -0,0 +1,30 @@ +import type { Mass } from '@/types/units'; +import * as units from '@/lib/utils/units'; +import { useEditorStore } from '@/app/hooks/stores/useEditorStore'; +import { useMoney } from '@/app/hooks/locales/useMoney'; +import { useGalleryTranslations } from '@/app/hooks/locales/translations/pages/public'; + +type Props = { id: string; mass: Mass }; +const PrintInfo = ({ id, mass }: Props) => { + const state = { + weight: useEditorStore(id, (state) => state.weight), + cost: useEditorStore(id, (state) => state.cost), + }; + const tEditor = useGalleryTranslations('editor'); + + const cost = useMoney(state.cost); + const weight = units.weight(state.weight, mass); + + return ( +

+ + {tEditor('weight')}: {weight} + + + {tEditor('cost')}: {cost} + +

+ ); +}; + +export default PrintInfo; diff --git a/src/app/pages/public/editor/looks/color.tsx b/src/app/pages/public/editor/looks/color.tsx new file mode 100644 index 0000000..5a503cd --- /dev/null +++ b/src/app/pages/public/editor/looks/color.tsx @@ -0,0 +1,47 @@ +import { X } from 'lucide-react'; +import * as editor from '@/app/stores/editor'; +import { useGalleryTranslations } from '@/app/hooks/locales/translations/pages/public'; +import { useEditorStore } from '@/app/hooks/stores/useEditorStore'; +import { Button } from '@/app/components/ui/button'; +import { Input } from '@/app/components/ui/input'; +import { Label } from '@/app/components/ui/label'; + +const Color = ({ id }: { id: string }) => { + const tEditor = useGalleryTranslations('editor'); + + const color = useEditorStore(id, (state) => state.color); + const actions = editor.getActions(id); + + return ( +
+ actions.set.color(e.target.value)} + className='absolute w-0 invisible' + /> +
+
+ ); +}; + +export default Color; diff --git a/src/app/pages/public/editor/looks/dropdown.tsx b/src/app/pages/public/editor/looks/dropdown.tsx new file mode 100644 index 0000000..f8a00a2 --- /dev/null +++ b/src/app/pages/public/editor/looks/dropdown.tsx @@ -0,0 +1,45 @@ +import { type MaterialResponse } from '@customcads/react-sdk'; +import { Children } from '@/types/react'; +import { cn } from '@/lib/utils/tailwindcss'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/app/components/ui/dropdown-menu'; +import * as materials_utils from '@/app/utils/materials'; + +type Props = { + materials: MaterialResponse[]; + current: MaterialResponse['id']; + onClick?: (material: MaterialResponse) => void; +} & Children; +const MaterialsDropdown = ({ + children, + materials, + current, + onClick, +}: Props) => ( + + {children} + +
    + {materials.map((x) => ( +
  • onClick?.(x)} + className={cn( + 'cursor-pointer', + current === x.id + ? 'opacity-50' + : 'hover:opacity-70', + )} + > + {materials_utils.format({ material: x, cost: true })} +
  • + ))} +
+
+
+); + +export default MaterialsDropdown; diff --git a/src/app/pages/public/editor/looks/index.ts b/src/app/pages/public/editor/looks/index.ts new file mode 100644 index 0000000..f21fdae --- /dev/null +++ b/src/app/pages/public/editor/looks/index.ts @@ -0,0 +1,2 @@ +export { default as Color } from './color'; +export { default as Materials } from './materials'; diff --git a/src/app/pages/public/editor/looks/materials.tsx b/src/app/pages/public/editor/looks/materials.tsx new file mode 100644 index 0000000..efe1f2c --- /dev/null +++ b/src/app/pages/public/editor/looks/materials.tsx @@ -0,0 +1,41 @@ +import { useQuery } from '@customcads/react-sdk'; +import { AppError } from '@/types/errors'; +import { BrickWall } from 'lucide-react'; +import * as editor from '@/app/stores/editor'; +import { useEditorStore } from '@/app/hooks/stores/useEditorStore'; +import Loader from '@/app/components/loading'; +import * as material_utils from '@/app/utils/materials'; +import MaterialsDropdown from './dropdown'; + +const Materials = ({ id }: { id: string }) => { + const materialId = useEditorStore(id, (state) => state.materialId); + const actions = editor.getActions(id); + + const { data: materials } = useQuery(({ materials }) => materials.all); + if (!materials) return ; + + const current = materials.find((x) => x.id === materialId); + if (!current) + throw new AppError({ + title: 'Texture Error', + message: 'An error occured during the Rendering process', + tip: 'Reload this page or clear your browser cache.', + }); + + return ( + actions.set.materialId(id)} + > +
+ + {current && ( + {material_utils.format({ material: current })} + )} +
+
+ ); +}; + +export default Materials; diff --git a/src/app/pages/public/editor/radio/distance.tsx b/src/app/pages/public/editor/radio/distance.tsx new file mode 100644 index 0000000..af7f1fb --- /dev/null +++ b/src/app/pages/public/editor/radio/distance.tsx @@ -0,0 +1,17 @@ +import { useGalleryTranslations } from '@/app/hooks/locales/translations/pages/public'; +import { useRadioGroups } from '../hooks/useRadioGroups'; +import RadioUi from './ui'; + +type Props = { ui: ReturnType['ui'] }; +const DistanceRadio = ({ ui }: Props) => { + const tEditor = useGalleryTranslations('editor'); + + const { Wrapper, items } = ui.distance(['mm', 'cm', 'inch']); + return ( + + {items} + + ); +}; + +export default DistanceRadio; diff --git a/src/app/pages/public/editor/radio/index.ts b/src/app/pages/public/editor/radio/index.ts new file mode 100644 index 0000000..d8c261c --- /dev/null +++ b/src/app/pages/public/editor/radio/index.ts @@ -0,0 +1,2 @@ +export { default as DistanceRadio } from './distance'; +export { default as MassRadio } from './mass'; diff --git a/src/app/pages/public/editor/radio/mass.tsx b/src/app/pages/public/editor/radio/mass.tsx new file mode 100644 index 0000000..c0d86da --- /dev/null +++ b/src/app/pages/public/editor/radio/mass.tsx @@ -0,0 +1,17 @@ +import { useGalleryTranslations } from '@/app/hooks/locales/translations/pages/public'; +import { useRadioGroups } from '../hooks/useRadioGroups'; +import RadioUi from './ui'; + +type Props = { ui: ReturnType['ui'] }; +const MassRadio = ({ ui }: Props) => { + const tEditor = useGalleryTranslations('editor'); + + const { Wrapper, items } = ui.mass(['g', 'kg', 'lbs']); + return ( + + {items} + + ); +}; + +export default MassRadio; diff --git a/src/app/pages/public/editor/radio/ui.tsx b/src/app/pages/public/editor/radio/ui.tsx new file mode 100644 index 0000000..c28ff6e --- /dev/null +++ b/src/app/pages/public/editor/radio/ui.tsx @@ -0,0 +1,11 @@ +import { Children } from '@/types/react'; + +type Props = { title: string } & Children; +const RadioUi = ({ children, title }: Props) => ( +
+ + {children} +
+); + +export default RadioUi; diff --git a/src/app/pages/public/editor/range/index.ts b/src/app/pages/public/editor/range/index.ts new file mode 100644 index 0000000..bcb29e7 --- /dev/null +++ b/src/app/pages/public/editor/range/index.ts @@ -0,0 +1,2 @@ +export { default as InfillRange } from './infill'; +export { default as ScaleRange } from './scale'; diff --git a/src/app/pages/public/editor/range/infill.tsx b/src/app/pages/public/editor/range/infill.tsx new file mode 100644 index 0000000..2142f4d --- /dev/null +++ b/src/app/pages/public/editor/range/infill.tsx @@ -0,0 +1,56 @@ +import { CircleQuestionMark } from 'lucide-react'; +import * as units from '@/lib/utils/units'; +import { cn } from '@/lib/utils/tailwindcss'; +import { INFILL } from '@/app/constants/threejs'; +import * as editor from '@/app/stores/editor'; +import { useEditorStore } from '@/app/hooks/stores/useEditorStore'; +import { useGalleryTranslations } from '@/app/hooks/locales/translations/pages/public'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/app/components/ui/tooltip'; +import RangeUi from './ui'; + +const InfillRange = ({ id }: { id: string }) => { + const tEditor = useGalleryTranslations('editor'); + + const actions = editor.getActions(id); + const infill = useEditorStore(id, (state) => state.infill); + + return ( + actions.set.infill(e.sortedValues[0])} + stepSize={0.0001} + > +
+

+ + + + + + {tEditor('infill-description')} + + + {tEditor('infill')}: + {units.percentage(100 * infill)} +

+ 0.3 && 'opacity-100', + )} + > + ({tEditor('unrecommended')}) + +
+
+ ); +}; + +export default InfillRange; diff --git a/src/app/pages/public/editor/range/scale.tsx b/src/app/pages/public/editor/range/scale.tsx new file mode 100644 index 0000000..9f707f3 --- /dev/null +++ b/src/app/pages/public/editor/range/scale.tsx @@ -0,0 +1,33 @@ +import * as units from '@/lib/utils/units'; +import * as editor from '@/app/stores/editor'; +import { useEditorStore } from '@/app/hooks/stores/useEditorStore'; +import { useGalleryTranslations } from '@/app/hooks/locales/translations/pages/public'; +import * as calculate3D from '@/app/utils/calculate-3D'; +import RangeUi from './ui'; + +const ScaleRange = ({ id }: { id: string }) => { + const state = { + size: useEditorStore(id, (state) => state.size), + scale: useEditorStore(id, (state) => state.scale), + }; + const actions = editor.getActions(id); + + const tEditor = useGalleryTranslations('editor'); + const ratio = calculate3D.baseRatio(state.size); + + return ( + actions.set.scale(e.sortedValues[0])} + stepSize={0.0001} + > + + {tEditor('scale')}: {units.percentage(100 * state.scale)} + + + ); +}; + +export default ScaleRange; diff --git a/src/app/pages/public/editor/range/ui.tsx b/src/app/pages/public/editor/range/ui.tsx new file mode 100644 index 0000000..c5b599c --- /dev/null +++ b/src/app/pages/public/editor/range/ui.tsx @@ -0,0 +1,12 @@ +import type { Children } from '@/types/react'; +import RangeField from '@/app/components/fields/range'; + +type Props = React.ComponentProps & Children; +const RangeUi = ({ children, ...props }: Props) => ( +
+ {children} + +
+); + +export default RangeUi; diff --git a/src/app/pages/public/editor/side.tsx b/src/app/pages/public/editor/side.tsx new file mode 100644 index 0000000..0c3b92f --- /dev/null +++ b/src/app/pages/public/editor/side.tsx @@ -0,0 +1,26 @@ +import { Children } from '@/types/react'; +import { cn } from '@/lib/utils/tailwindcss'; + +type Props = { orientation: 'left' | 'right' } & Children; +const Side = ({ children, orientation }: Props) => ( +
+
+ {children} +
+
+); + +export default Side; diff --git a/src/app/pages/public/gallery/hooks/useDropdowns.tsx b/src/app/pages/public/gallery/hooks/useDropdowns.tsx new file mode 100644 index 0000000..ce1603f --- /dev/null +++ b/src/app/pages/public/gallery/hooks/useDropdowns.tsx @@ -0,0 +1,88 @@ +import { getRouteApi } from '@tanstack/react-router'; +import { useQuery } from '@customcads/react-sdk'; +import { useFormTranslations } from '@/app/hooks/locales/translations/components'; +import * as limits from '@/app/constants/limits'; +import Categories from '@/app/components/search/categories'; +import Searchbar from '@/app/components/search/searchbar'; +import Sortings from '@/app/components/search/sortings'; +import Pagination from '@/app/components/search/pagination'; + +const Route = getRouteApi('/_public/gallery/'); + +export const useDropdowns = (count?: number) => { + const tPlaceholders = useFormTranslations('placeholders'); + + const navigate = Route.useNavigate(); + const search = Route.useSearch(); + + const { data: sortings } = useQuery( + ({ products }) => products.gallery.sortings, + ); + + return { + Categories: () => ( + search.categoryName} + updateCategory={(category) => { + navigate({ + search: (prev) => ({ + ...prev, + categoryName: category?.name, + }), + }); + }} + /> + ), + Searchbar: () => ( + search.name} + updateSearch={(name) => { + navigate({ + search: (prev) => ({ + ...prev, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + name: name || undefined, + }), + }); + }} + /> + ), + Sortings: () => ( + ({ + type: search.sortingType, + direction: search.sortingDirection, + })} + updateSorting={({ type, direction }) => { + navigate({ + search: (prev) => ({ + ...prev, + sortingType: type ?? prev.sortingType, + sortingDirection: direction, + }), + }); + }} + /> + ), + Pagination: () => ( + + navigate({ + search: (prev) => ({ + ...prev, + page, + limit, + }), + }) + } + /> + ), + }; +}; diff --git a/src/app/pages/public/gallery/index.tsx b/src/app/pages/public/gallery/index.tsx new file mode 100644 index 0000000..c64ffae --- /dev/null +++ b/src/app/pages/public/gallery/index.tsx @@ -0,0 +1,64 @@ +import { getRouteApi } from '@tanstack/react-router'; +import { useQuery } from '@customcads/react-sdk'; +import { cn } from '@/lib/utils/tailwindcss'; +import { useEmptyTranslations } from '@/app/hooks/locales/translations/common'; +import * as page from '@/app/utils/page'; +import { useDropdowns } from './hooks/useDropdowns'; +import Item from './item'; + +const Route = getRouteApi('/_public/gallery/'); + +const Gallery = () => { + const { galleryQueryArgs } = Route.useLoaderData(); + const tEmpty = useEmptyTranslations(); + + const { data: products } = useQuery(({ products }) => + products.gallery.all(galleryQueryArgs), + ); + const dropdowns = useDropdowns(products?.count); + + return ( +
+
+
+ + +
+ +
+
+ + + +
+ {products?.count ? ( +
    + {products.items.map((x) => ( +
  • + +
  • + ))} +
+ ) : ( + + {tEmpty('products')} + + )} + +
+ ); +}; + +export default Gallery; diff --git a/src/app/pages/public/gallery/item.tsx b/src/app/pages/public/gallery/item.tsx new file mode 100644 index 0000000..739d3b5 --- /dev/null +++ b/src/app/pages/public/gallery/item.tsx @@ -0,0 +1,47 @@ +import { Link } from '@tanstack/react-router'; +import { + useQuery, + type GalleryAllProductsResponse, +} from '@customcads/react-sdk'; +import { Banknote, Eye } from 'lucide-react'; +import { cn } from '@/lib/utils/tailwindcss'; + +const Item = ({ product }: { product: GalleryAllProductsResponse }) => { + const { data: image } = useQuery( + ({ images }) => + images.download({ id: product.imageId, relationType: 'Product' }), + !!product, + ); + + return ( + + +
+
+ + {product.counts.views} +
+ + {product.name} + +
+ + {product.counts.purchases} +
+
+ + ); +}; + +export default Item; diff --git a/src/app/pages/public/home/index.tsx b/src/app/pages/public/home/index.tsx index 7cfcf44..4a529c1 100644 --- a/src/app/pages/public/home/index.tsx +++ b/src/app/pages/public/home/index.tsx @@ -8,7 +8,7 @@ const Home = () => { const tHome = useInfoTranslations('home'); return ( -
+
); diff --git a/src/app/pages/public/home/section-1/index.tsx b/src/app/pages/public/home/section-1/index.tsx index 5a4c49b..24452c0 100644 --- a/src/app/pages/public/home/section-1/index.tsx +++ b/src/app/pages/public/home/section-1/index.tsx @@ -7,7 +7,7 @@ type Props = { tSection1: (key: SuffixOf) => string }; const Section1 = ({ tSection1 }: Props) => (
(

{tSection1('subtitle')}

-
diff --git a/src/app/pages/public/product/card.tsx b/src/app/pages/public/product/card.tsx new file mode 100644 index 0000000..bd9421c --- /dev/null +++ b/src/app/pages/public/product/card.tsx @@ -0,0 +1,34 @@ +import { GallerySingleProductResponse } from '@customcads/react-sdk'; +import { useGalleryTranslations } from '@/app/hooks/locales/translations/pages/public'; +import { useMoney } from '@/app/hooks/locales/useMoney'; +import Cad from '@/app/components/cad'; + +type Props = { product: GallerySingleProductResponse }; +const Card = ({ product }: Props) => { + const tProduct = useGalleryTranslations('product'); + const price = useMoney(product.price); + + return ( +
+
+

+ {tProduct('price')}: + {price} +

+

+ {product.name} +

+

+ {tProduct('category')}: + {product.category.name} +

+
+ +

{product.description}

+
+ ); +}; + +export default Card; diff --git a/src/app/pages/public/product/index.tsx b/src/app/pages/public/product/index.tsx new file mode 100644 index 0000000..f4eca06 --- /dev/null +++ b/src/app/pages/public/product/index.tsx @@ -0,0 +1,34 @@ +import { getRouteApi } from '@tanstack/react-router'; +import { useQuery } from '@customcads/react-sdk'; +import { cn } from '@/lib/utils/tailwindcss'; +import Loader from '@/app/components/loading'; +import * as page from '@/app/utils/page'; +import Card from './card'; +import Info from './info'; +// import Tags from './tags'; + +const Route = getRouteApi('/_public/gallery/$id'); + +const Product = () => { + const { id } = Route.useParams(); + const { data: product } = useQuery(({ products }) => + products.gallery.single({ id }), + ); + + if (!product) return ; + + return ( +
+

+ {product.name} +

+
+ + + {/* */} +
+
+ ); +}; + +export default Product; diff --git a/src/app/pages/public/product/info/add/button.tsx b/src/app/pages/public/product/info/add/button.tsx new file mode 100644 index 0000000..bcafe0a --- /dev/null +++ b/src/app/pages/public/product/info/add/button.tsx @@ -0,0 +1,31 @@ +import { LucideIcon } from 'lucide-react'; +import { cn } from '@/lib/utils/tailwindcss'; +import { Button } from '@/app/components/ui/button'; + +type Props = { + Icon: LucideIcon; + text: string; + className?: string; + onClick?: VoidFunction; +}; +const ProductButton = ({ Icon, text, className, onClick }: Props) => ( +
+ + +
+); + +export default ProductButton; diff --git a/src/app/pages/public/product/info/add/index.tsx b/src/app/pages/public/product/info/add/index.tsx new file mode 100644 index 0000000..e3b4198 --- /dev/null +++ b/src/app/pages/public/product/info/add/index.tsx @@ -0,0 +1,86 @@ +import { useState } from 'react'; +import { useNavigate } from '@tanstack/react-router'; +import { LucideIcon } from 'lucide-react'; +import { useCartUpdates } from '@/app/hooks/features/carts/useCartUpdates'; +import { useGalleryTranslations } from '@/app/hooks/locales/translations/pages/public'; +import * as popover from '@/app/components/ui/popover'; +import { Button } from '@/app/components/ui/button'; +import * as productTags from '@/app/utils/product-tags'; +import ProductButton from './button'; + +type Props = { + Icon: LucideIcon; + product: { id: string; is: ReturnType }; + text: string; + isAdded: boolean; + className?: string; +}; +const AddToCartButton = ({ + Icon, + product, + text, + isAdded, + className, +}: Props) => { + const navigate = useNavigate(); + const updates = useCartUpdates(); + + const tProduct = useGalleryTranslations('product'); + const [isOpen, setIsOpen] = useState(false); + + const toggleIsOpen = (open: boolean) => setIsOpen(isAdded ? false : open); + const handleAdd = { + noDelivery: () => { + updates.cart.add({ + productId: product.id, + forDelivery: false, + }); + setIsOpen(false); + }, + forDelivery: () => { + navigate({ to: '/editor/$id', params: { id: product.id } }); + setIsOpen(false); + }, + }; + + if (!product.is.printable) + return ( + + ); + + return ( + + + + + + + + + ); +}; + +export default AddToCartButton; diff --git a/src/app/pages/public/product/info/hooks/useButtons.tsx b/src/app/pages/public/product/info/hooks/useButtons.tsx new file mode 100644 index 0000000..d1f1a07 --- /dev/null +++ b/src/app/pages/public/product/info/hooks/useButtons.tsx @@ -0,0 +1,113 @@ +import { useState } from 'react'; +import { HeartMinus, HeartPlus, ListCheck, ListPlus } from 'lucide-react'; +import { cn } from '@/lib/utils/tailwindcss'; +import { useGalleryTranslations } from '@/app/hooks/locales/translations/pages/public'; +import { useCartStore } from '@/app/hooks/stores/useCartStore'; +import { Button } from '@/app/components/ui/button'; +import * as productTags from '@/app/utils/product-tags'; +import AddToCartButton from '../add'; + +type Props = { id: string; is: ReturnType }; +const useButtons = (product: Props) => { + const tProduct = useGalleryTranslations('product'); + const { items } = useCartStore(); + + const [isLiked, setIsLiked] = useState(false); // TODO: implement real Like mechanism + const toggleIsLiked = () => setIsLiked((prev) => !prev); + + const isAlreadyLiked = isLiked; + const like = { + Icon: isAlreadyLiked ? HeartMinus : HeartPlus, + text: ({ isShort }: { isShort: boolean }) => { + if (isAlreadyLiked) { + return isShort ? tProduct('liked-short') : tProduct('liked'); + } + return isShort ? tProduct('like-short') : tProduct('like'); + }, + className: cn( + 'shadow-shadow shadow-md cursor-pointer', + isLiked && 'bg-pink-700 hover:bg-pink-800', + ), + }; + + const isAlreadyAdded = !!items?.find((i) => i.productId === product.id); + const add = { + Icon: isAlreadyAdded ? ListCheck : ListPlus, + text: ({ isShort }: { isShort: boolean }) => { + if (isAlreadyAdded) { + return isShort ? tProduct('added-short') : tProduct('added'); + } + return isShort ? tProduct('add-short') : tProduct('add'); + }, + className: cn( + 'shadow-shadow shadow-md', + isAlreadyAdded ? 'opacity-80 hover:bg-secondary' : 'cursor-pointer', + ), + }; + + return { + like: { + mobile: ( +
+ + +
+ ), + desktop: ( +
+ + +
+ ), + }, + add: { + mobile: ( + + ), + desktop: ( + + ), + }, + }; +}; + +export default useButtons; diff --git a/src/app/pages/public/product/info/index.tsx b/src/app/pages/public/product/info/index.tsx new file mode 100644 index 0000000..ee80899 --- /dev/null +++ b/src/app/pages/public/product/info/index.tsx @@ -0,0 +1,53 @@ +import { Link } from '@tanstack/react-router'; +import { GallerySingleProductResponse } from '@customcads/react-sdk'; +import { useGalleryTranslations } from '@/app/hooks/locales/translations/pages/public'; +import * as dateTime from '@/app/utils/date-time'; +import * as productTags from '@/app/utils/product-tags'; +import useButtons from './hooks/useButtons'; + +type Props = { product: GallerySingleProductResponse }; +const Info = ({ product: { id, creatorName, tags, uploadedAt } }: Props) => { + const tProduct = useGalleryTranslations('product'); + + const by = ( +

+ {tProduct('creator')}: + + {creatorName} + +

+ ); + const at = ( +

+ + {dateTime.format({ date: uploadedAt })} + +

+ ); + + const productIs = productTags.is(tags); + const { add, like } = useButtons({ id, is: productIs }); + + return ( +
+
+
+
{by}
+
{at}
+
+
+ {like.mobile} + {add.mobile} +
+
+
+ {by} + {like.desktop} + {add.desktop} + {at} +
+
+ ); +}; + +export default Info; diff --git a/src/app/pages/public/product/tags.tsx b/src/app/pages/public/product/tags.tsx new file mode 100644 index 0000000..c511242 --- /dev/null +++ b/src/app/pages/public/product/tags.tsx @@ -0,0 +1,14 @@ +const Tags = ({ tags }: { tags: string[] }) => ( +
    + {tags.map((x) => ( +
  • + {x} +
  • + ))} +
+); + +export default Tags; diff --git a/src/app/stores/cart.ts b/src/app/stores/cart.ts new file mode 100644 index 0000000..d560bd4 --- /dev/null +++ b/src/app/stores/cart.ts @@ -0,0 +1,147 @@ +import { Store } from '@tanstack/store'; +import * as persistence from '@/lib/isomorphic/persistence'; +import { getEnv } from '@/lib/isomorphic/env'; +import * as auth from '@/lib/utils/auth'; +import { CartItem } from '@/app/types/cart-item'; +import { CART } from '@/app/constants/stores'; + +export const persist = persistence.create(CART.store, (state) => ({ + data: state.items, + key: CART.cookie, +})); + +type CartState = { + items: CartItem[] | null; +}; +const defaultState = (): CartState => { + if (getEnv().isServer) return { items: null }; + if (auth.is().customer) return { items: null }; + + const persistedState = persistence.get(CART.store); + if (persistedState) return JSON.parse(persistedState); + + const state = { items: [] }; + persist(state); + return state; +}; + +export const store = new Store(defaultState()); +store.subscribe(({ currentVal }) => persist(currentVal)); + +export const actions = { + cart: { + fill: (items: CartItem[]) => store.setState({ items }), + clear: () => store.setState({ items: [] }), + add: (newItem: CartItem) => + store.setState((prev) => { + if (!prev.items) return { items: [newItem] }; + + return prev.items.find( + (item) => item.productId === newItem.productId, + ) + ? prev + : { items: [...prev.items, newItem] }; + }), + remove: (id: string) => + store.setState((prev) => { + if (!prev.items) return prev; + + return { + items: prev.items.filter((item) => item.productId !== id), + }; + }), + }, + item: { + quantity: { + increment: (id: string) => + store.setState((prev) => { + if (!prev.items) return prev; + + return { + items: prev.items.map((item) => + item.productId === id && item.forDelivery + ? { ...item, quantity: item.quantity + 1 } + : item, + ), + }; + }), + decrement: (id: string) => + store.setState((prev) => { + if (!prev.items) return prev; + + return { + items: prev.items.map((item) => + item.productId === id && + item.forDelivery && + item.quantity > 1 + ? { ...item, quantity: item.quantity - 1 } + : item, + ), + }; + }), + }, + delivery: { + on: (id: string, customizationId: string) => + store.setState((prev) => { + if (!prev.items) return prev; + + if (prev.items.find((item) => item.productId === id)) { + return { + items: prev.items.map((item) => { + if (item.productId === id && !item.forDelivery) + return { + ...item, + forDelivery: true, + quantity: 1, + customizationId, + }; + + return item; + }), + }; + } + + return prev; + }), + off: (id: string) => + store.setState((prev) => { + if (!prev.items) return prev; + + if (prev.items.find((item) => item.productId === id)) { + return { + items: prev.items.map((item) => { + if (item.productId === id && item.forDelivery) + return { + ...item, + forDelivery: false, + customizationId: null, + }; + + return item; + }), + }; + } + + return prev; + }), + }, + weight: { + set: (id: string, weight: number) => + store.setState((prev) => { + if (!prev.items) return prev; + + if (prev.items.find((item) => item.productId === id)) { + return { + items: prev.items.map((item) => + item.productId === id && item.forDelivery + ? { ...item, weight: weight } + : item, + ), + }; + } + + return prev; + }), + }, + }, +}; diff --git a/src/app/stores/currency.ts b/src/app/stores/currency.ts new file mode 100644 index 0000000..9e374b6 --- /dev/null +++ b/src/app/stores/currency.ts @@ -0,0 +1,39 @@ +import { Store } from '@tanstack/store'; +import { Currency } from '@customcads/react-sdk'; +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 persist = persistence.create(CURRENCY.store, (state) => ({ + data: state.current, + key: CURRENCY.cookie, +})); +const defaultBrowserCurrency = money.resolveCurrency(); + +type CurrencyState = { + current: Currency; +}; + +const loadInitialState = (): CurrencyState => { + if (getEnv().isServer) return { current: 'EUR' }; + + const persistedState = persistence.get(CURRENCY.store); + if (persistedState) return JSON.parse(persistedState); + + const state = { current: defaultBrowserCurrency }; + persist(state); + return state; +}; + +export const store = new Store(loadInitialState()); +store.subscribe(({ currentVal }) => persist(currentVal)); + +export const resetStore = () => + store.setState({ current: defaultBrowserCurrency }); + +export const setCurrent = (currentCurr: Currency) => + store.setState((prev) => ({ + ...prev, + current: currentCurr, + })); diff --git a/src/app/stores/editor.ts b/src/app/stores/editor.ts new file mode 100644 index 0000000..96b836d --- /dev/null +++ b/src/app/stores/editor.ts @@ -0,0 +1,120 @@ +import { Store } from '@tanstack/store'; +import { Ratio } from '@/types/threejs'; +import * as persistence from '@/lib/isomorphic/persistence'; +import { EDITOR } from '@/app/constants/stores'; +import { getEnv } from '@/lib/isomorphic/env'; + +const persist = persistence.create(EDITOR.store); + +type EditorState = { + materialId: number; + color: string; + infill: number; + size: Ratio; + scale: number; + weight: number; + cost: number; +}; +export const defaultState: EditorState = { + materialId: 1, + color: '#ffffff', + infill: 0.2, + size: { x: 0, y: 0, z: 0 }, + scale: 1, + weight: 0, + cost: 0, +}; + +const loadInitialState = (): Record => { + if (getEnv().isServer) return {}; + + const persistedState = persistence.get(EDITOR.store); + if (persistedState) return JSON.parse(persistedState); + + const state = {}; + persist(state); + return state; +}; + +export const store = new Store>(loadInitialState()); +store.subscribe(({ currentVal }) => persist(currentVal)); + +const resetRecord = (id: string | null) => + store.setState((prev) => { + if (!id) return {}; + + return { + ...prev, + [id]: defaultState, + }; + }); + +const addRecord = (id: string) => + store.setState((prev) => ({ + ...prev, + [id]: defaultState, + })); + +const removeRecord = (id: string) => + store.setState((prev) => + Object.fromEntries(Object.entries(prev).filter((x) => x[0] !== id)), + ); + +const setMaterialId = (id: string, materialId: number) => + store.setState((prev) => ({ + ...prev, + [id]: { ...prev[id], materialId }, + })); + +const setColor = (id: string, color: string) => + store.setState((prev) => ({ + ...prev, + [id]: { ...prev[id], color }, + })); + +const setInfill = (id: string, infill: number) => + store.setState((prev) => ({ + ...prev, + [id]: { ...prev[id], infill }, + })); + +const setSize = (id: string, size: Ratio) => + store.setState((prev) => ({ + ...prev, + [id]: { ...prev[id], size }, + })); + +const setScale = (id: string, scale: number) => + store.setState((prev) => ({ + ...prev, + [id]: { ...prev[id], scale }, + })); + +const setWeight = (id: string, weight: number) => + store.setState((prev) => ({ + ...prev, + [id]: { ...prev[id], weight }, + })); + +const setCost = (id: string, cost: number) => + store.setState((prev) => ({ + ...prev, + [id]: { ...prev[id], cost }, + })); + +export const getActions = (id: string) => ({ + record: { + add: () => addRecord(id), + remove: () => removeRecord(id), + reset: () => resetRecord(id), + }, + set: { + materialId: (materialId: number) => setMaterialId(id, materialId), + color: (color: string) => setColor(id, color), + infill: (infill: number) => setInfill(id, infill), + size: (size: Ratio) => setSize(id, size), + scale: (scale: number) => setScale(id, scale), + weight: (weight: number) => setWeight(id, weight), + cost: (cost: number) => setCost(id, cost), + }, +}); 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/app/types/cart-item.ts b/src/app/types/cart-item.ts new file mode 100644 index 0000000..6e151d3 --- /dev/null +++ b/src/app/types/cart-item.ts @@ -0,0 +1,13 @@ +export type CartItemForDelivery = { + forDelivery: true; + productId: string; + quantity: number; + customizationId: string; +}; + +export type CartItemNoDelivery = { + forDelivery: false; + productId: string; +}; + +export type CartItem = CartItemForDelivery | CartItemNoDelivery; diff --git a/src/app/types/material.ts b/src/app/types/material.ts new file mode 100644 index 0000000..f459c82 --- /dev/null +++ b/src/app/types/material.ts @@ -0,0 +1,5 @@ +export type Material = { + blobUrl: string; + euroPerKg: number; + density: number; +}; diff --git a/src/app/utils/calculate-3D.ts b/src/app/utils/calculate-3D.ts new file mode 100644 index 0000000..8259008 --- /dev/null +++ b/src/app/utils/calculate-3D.ts @@ -0,0 +1,52 @@ +import { COST, RATIO, WEIGHT } from '@/app/constants/threejs'; +import { Ratio } from '@/types/threejs'; + +const getMinSize = (size: Ratio) => { + if (!size.x || !size.y || !size.z) return 0; + + const { x, y, z } = { + x: RATIO.min.x / size.x, + y: RATIO.min.y / size.y, + z: RATIO.min.z / size.z, + }; + return Math.max(x, y, z); +}; + +export const getMaxRatio = (ratio: Ratio) => { + const { x, y, z } = { + x: RATIO.max.x / ratio.x, + y: RATIO.max.y / ratio.y, + z: RATIO.max.z / ratio.z, + }; + return Math.min(x, y, z); +}; + +export const baseRatio = (size: Ratio) => { + const { x, y, z } = size; + const base = getMinSize(size); + return { x: base * x, y: base * y, z: base * z }; +}; + +export const volumeMm3 = (volume: number, scale: number, size: Ratio) => { + const scaledVolume = volume * scale ** 3; + const min = getMinSize(size); + return scaledVolume * min ** 3; +}; + +export const weightGrams = ( + volumeMm3: number, + infill: number, + density: number, +) => { + const effectiveVolumeMm3 = + volumeMm3 * (WEIGHT.wallFactor + (1 - WEIGHT.wallFactor) * infill); + + const effectiveVolumeCm3 = effectiveVolumeMm3 / 1000; + return effectiveVolumeCm3 * density; +}; + +export const costEUR = (weightInG: number, euroPerKg: number) => { + const printCost = (weightInG / 1000) * euroPerKg; + const finalCost = printCost * COST.profitMultiplier + COST.profitBase; + return finalCost; +}; diff --git a/src/app/utils/materials.ts b/src/app/utils/materials.ts new file mode 100644 index 0000000..b686b11 --- /dev/null +++ b/src/app/utils/materials.ts @@ -0,0 +1,10 @@ +import { type MaterialResponse } from '@customcads/react-sdk'; + +type FormatProps = { material: MaterialResponse; cost?: boolean }; +export const format = ({ material, cost }: FormatProps) => { + const n = material.name; + const d = `: ${material.density}g/cm³`; + const c = ` (${material.cost}€/kg)`; + + return cost ? n + c + d : n + d; +}; diff --git a/src/app/utils/money.ts b/src/app/utils/money.ts new file mode 100644 index 0000000..d72e064 --- /dev/null +++ b/src/app/utils/money.ts @@ -0,0 +1,46 @@ +import { Currency, ExchangeRate, EXCHANGE_RATES } from '@customcads/react-sdk'; +import { getUserDefaultLanguage } from '@/lib/isomorphic/language'; +import { invertBy } from '@/lib/utils/typescript'; + +const currencies = invertBy(EXCHANGE_RATES, 'language'); +const { EUR } = EXCHANGE_RATES; + +const currencyToRate = (rates: ExchangeRate[], currency: Currency) => { + const rate = rates.find((x) => x.currency === currency); + if (!rate) return rate; + + return { + rate: rate.rate, + symbol: EXCHANGE_RATES[currency].symbol, + }; +}; + +export const resolveCurrency = (currency?: Currency) => + currency ?? currencies[getUserDefaultLanguage()] ?? 'EUR'; + +export const currencyToSymbol = (currency?: Currency) => + EXCHANGE_RATES[resolveCurrency(currency)].symbol; + +type FromBaseOptions = { money: number; to?: Currency; rates?: ExchangeRate[] }; +export const fromBase = ({ money, to, rates }: FromBaseOptions) => { + to = resolveCurrency(to); + if (rates) { + const { rate, symbol } = currencyToRate(rates, to) ?? EUR; + return { money: money * rate, symbol }; + } + + const { rate, symbol } = EXCHANGE_RATES[to]; + return { money: money * rate, symbol }; +}; + +type ToBaseOptions = { money: number; from?: Currency; rates?: ExchangeRate[] }; +export const toBase = ({ money, from, rates }: ToBaseOptions) => { + from = resolveCurrency(from); + if (rates) { + const { rate } = currencyToRate(rates, from) ?? EUR; + return { money: money / rate, symbol: EUR.symbol }; + } + + const { rate } = EXCHANGE_RATES[from]; + return { money: money / rate, symbol: EUR.symbol }; +}; diff --git a/src/app/utils/page.ts b/src/app/utils/page.ts index e6abe83..b3d75b0 100644 --- a/src/app/utils/page.ts +++ b/src/app/utils/page.ts @@ -1,2 +1,2 @@ export const className = - 'basis-full grow flex flex-col justify-center items-center p-10 animate-fade-in'; + 'basis-full grow flex flex-col justify-center items-center p-6 md:p-10 animate-fade-in'; 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; +}; diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..6e6ed5a --- /dev/null +++ b/src/client.ts @@ -0,0 +1,4 @@ +import { StartClient } from '@tanstack/react-start/client'; +import { hydrateRoot } from 'react-dom/client'; + +hydrateRoot(document, StartClient()); diff --git a/src/hooks/headless/usePagination.ts b/src/hooks/headless/usePagination.ts index 231358c..23e3c00 100644 --- a/src/hooks/headless/usePagination.ts +++ b/src/hooks/headless/usePagination.ts @@ -1,28 +1,46 @@ import { useState } from 'react'; -export const usePagination = ( - total: number, - defaultLimit: number, - autoLoop?: boolean, -) => { - const [{ page, limit }, setPagination] = useState({ - page: 1, - limit: defaultLimit, - }); +type Props = { + total: number; + defaultPagination: { page: number; limit: number }; + autoLoop?: boolean; + onChange?: (pagination: { page: number; limit: number }) => void; +}; +export const usePagination = ({ + defaultPagination, + total, + autoLoop, + onChange, +}: Props) => { + const [{ page, limit }, setPagination] = useState(defaultPagination); + const renewPagination = (pagination: { page: number; limit: number }) => { + setPagination(pagination); + onChange?.(pagination); + }; return { - page, - limit, - handlePageChange: (newPage: number) => { - if (newPage >= 1 && newPage <= Math.ceil(total / limit)) { - setPagination({ limit, page: newPage }); - } else if (autoLoop) { - if (newPage < 1) { - setPagination({ limit, page: Math.ceil(total / limit) }); - } else { - setPagination({ limit, page: 1 }); + state: { + page, + limit, + }, + handleChange: { + page: (newPage: number) => { + if (newPage >= 1 && newPage <= Math.ceil(total / limit)) { + renewPagination({ limit, page: newPage }); + } else if (autoLoop) { + if (newPage < 1) { + renewPagination({ + limit, + page: Math.ceil(total / limit), + }); + } else { + renewPagination({ limit, page: 1 }); + } } - } + }, + limit: (newLimit: number) => { + renewPagination({ page, limit: newLimit }); + }, }, }; }; diff --git a/src/hooks/headless/useThreeJS.ts b/src/hooks/headless/useThreeJS.ts index 98481de..a42ff04 100644 --- a/src/hooks/headless/useThreeJS.ts +++ b/src/hooks/headless/useThreeJS.ts @@ -13,7 +13,6 @@ export const useThreeJS = ( loadCallback?: (cad: THREE.Group) => void, ) => { const [progress, setProgress] = useState(0); - const progressCallback = (percentage: number) => setProgress(percentage); const mountRef = useRef(null); const instanceRef = useRef | null>(null); @@ -27,14 +26,9 @@ export const useThreeJS = ( clearScene(scene); if (file.type === 'stl') { - loader.stl(scene, file.url, loadCallback, progressCallback); + loader.stl(scene, file.url, loadCallback, setProgress); } else { - loader.gltf( - scene, - file.url, - loadCallback, - progressCallback, - ); + loader.gltf(scene, file.url, loadCallback, setProgress); } } } @@ -43,7 +37,7 @@ export const useThreeJS = ( instanceRef.current?.exit(); instanceRef.current = null; }; - }, [file]); + }, [file?.type, file?.url, file?.coords.cam, file?.coords.pan]); return { ref: mountRef, instance: instanceRef.current, progress }; }; diff --git a/src/index.css b/src/index.css index 4702ada..e1606a1 100644 --- a/src/index.css +++ b/src/index.css @@ -28,7 +28,7 @@ code { --popover-foreground: oklch(0.141 0.005 285.823); --primary: oklch(0.21 0.006 285.885); --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.967 0.001 286.375); + --secondary: oklch(74.854% 0.00433 286.008 / 0.721); --secondary-foreground: oklch(0.21 0.006 285.885); --muted: oklch(0.967 0.001 286.375); --muted-foreground: oklch(0.552 0.016 285.938); @@ -81,7 +81,7 @@ code { --foreground: oklch(0.985 0 0); --card: oklch(21.952% 0.00777 285.704); --card-foreground: oklch(0.985 0 0); - --popover: oklch(14.077% 0.0044 285.776 / 0.885); + --popover: oklch(0.274 0.006 286.033); --popover-foreground: oklch(0.985 0 0); --primary: oklch(0.985 0 0); --primary-foreground: oklch(0.21 0.006 285.885); diff --git a/src/lib/cad/three-js.ts b/src/lib/cad/three-js.ts index 772f7b7..a460b11 100644 --- a/src/lib/cad/three-js.ts +++ b/src/lib/cad/three-js.ts @@ -95,7 +95,7 @@ const update = { camera: THREE.PerspectiveCamera, root: HTMLDivElement, ) => { - const { clientHeight: width, clientWidth: height } = root; + const { clientHeight: height, clientWidth: width } = root; renderer.setSize(width, height); camera.aspect = width / height; 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 5a9d3ac..ebcddab 100644 --- a/src/lib/isomorphic/language.ts +++ b/src/lib/isomorphic/language.ts @@ -1,12 +1,14 @@ -import { createClientOnlyFn, createIsomorphicFn } from '@tanstack/react-start'; -import { getCookie } from '@tanstack/react-start/server'; -import Cookies from 'js-cookie'; +import { createIsomorphicFn } from '@tanstack/react-start'; import { AllowedLanguage } from '@/types/locale'; +import { LANGUAGE } from '@/app/constants/stores'; +import { getCookie } from './persistence'; -export const getUserDefaultLanguage = createClientOnlyFn( - () => (navigator.languages || [navigator.language])[0] as AllowedLanguage, -); +export const getUserDefaultLanguage = createIsomorphicFn() + .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/lib/utils/auth.ts b/src/lib/utils/auth.ts index 6c40179..b94e0f6 100644 --- a/src/lib/utils/auth.ts +++ b/src/lib/utils/auth.ts @@ -1,5 +1,16 @@ -type IsProps = { authn: boolean; authz: string | null }; -export const is = ({ authn, authz }: IsProps) => { +import { getRoleCookie } from '../isomorphic/api'; + +type Props = { authn: boolean; authz: string | null }; +export type Returns = { + guest: boolean; + customer: boolean; + creator: boolean; + contributor: boolean; + designer: boolean; + admin: boolean; +}; + +const solve = ({ authn, authz }: Props): Returns => { const roles = { guest: !authn, customer: authn && authz === 'Customer', @@ -13,3 +24,11 @@ export const is = ({ authn, authz }: IsProps) => { creator: roles.contributor || roles.designer, }; }; + +export const is = (props?: Props): Returns => + props + ? solve(props) + : solve({ + authn: !!getRoleCookie(), + authz: getRoleCookie() ?? null, + }); diff --git a/src/lib/utils/units.ts b/src/lib/utils/units.ts index 147b51c..8338044 100644 --- a/src/lib/utils/units.ts +++ b/src/lib/utils/units.ts @@ -15,7 +15,7 @@ const G_PER_UNIT: Record = { }; export const percentage = (scale: number) => { - const value = parseFloat(scale.toFixed(2)); + const value = parseFloat(scale.toFixed(2)).toFixed(2); return `${value}%`; }; @@ -31,5 +31,5 @@ export const volume = (volume: number, unit: Distance) => { export const weight = (weight: number, unit: Mass) => { const value = parseFloat((weight / G_PER_UNIT[unit]).toFixed(POINTS)); - return `${value} ${unit}`; + return `${value}${unit}`; }; diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index f1a4482..04b8e56 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -10,43 +10,96 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as PublicIndexRouteImport } from './routes/_public/index' +import { Route as PublicCartRouteImport } from './routes/_public/cart' import { Route as GuestLoginRouteImport } from './routes/_guest/login' +import { Route as PublicGalleryIndexRouteImport } from './routes/_public/gallery/index' +import { Route as PublicGalleryIdRouteImport } from './routes/_public/gallery/$id' +import { Route as PublicEditorIdRouteImport } from './routes/_public/editor.$id' const PublicIndexRoute = PublicIndexRouteImport.update({ id: '/_public/', path: '/', getParentRoute: () => rootRouteImport, } as any) +const PublicCartRoute = PublicCartRouteImport.update({ + id: '/_public/cart', + path: '/cart', + getParentRoute: () => rootRouteImport, +} as any) const GuestLoginRoute = GuestLoginRouteImport.update({ id: '/_guest/login', path: '/login', getParentRoute: () => rootRouteImport, } as any) +const PublicGalleryIndexRoute = PublicGalleryIndexRouteImport.update({ + id: '/_public/gallery/', + path: '/gallery/', + getParentRoute: () => rootRouteImport, +} as any) +const PublicGalleryIdRoute = PublicGalleryIdRouteImport.update({ + id: '/_public/gallery/$id', + path: '/gallery/$id', + getParentRoute: () => rootRouteImport, +} as any) +const PublicEditorIdRoute = PublicEditorIdRouteImport.update({ + id: '/_public/editor/$id', + path: '/editor/$id', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/login': typeof GuestLoginRoute + '/cart': typeof PublicCartRoute '/': typeof PublicIndexRoute + '/editor/$id': typeof PublicEditorIdRoute + '/gallery/$id': typeof PublicGalleryIdRoute + '/gallery': typeof PublicGalleryIndexRoute } export interface FileRoutesByTo { '/login': typeof GuestLoginRoute + '/cart': typeof PublicCartRoute '/': typeof PublicIndexRoute + '/editor/$id': typeof PublicEditorIdRoute + '/gallery/$id': typeof PublicGalleryIdRoute + '/gallery': typeof PublicGalleryIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/_guest/login': typeof GuestLoginRoute + '/_public/cart': typeof PublicCartRoute '/_public/': typeof PublicIndexRoute + '/_public/editor/$id': typeof PublicEditorIdRoute + '/_public/gallery/$id': typeof PublicGalleryIdRoute + '/_public/gallery/': typeof PublicGalleryIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/login' | '/' + fullPaths: + | '/login' + | '/cart' + | '/' + | '/editor/$id' + | '/gallery/$id' + | '/gallery' fileRoutesByTo: FileRoutesByTo - to: '/login' | '/' - id: '__root__' | '/_guest/login' | '/_public/' + to: '/login' | '/cart' | '/' | '/editor/$id' | '/gallery/$id' | '/gallery' + id: + | '__root__' + | '/_guest/login' + | '/_public/cart' + | '/_public/' + | '/_public/editor/$id' + | '/_public/gallery/$id' + | '/_public/gallery/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { GuestLoginRoute: typeof GuestLoginRoute + PublicCartRoute: typeof PublicCartRoute PublicIndexRoute: typeof PublicIndexRoute + PublicEditorIdRoute: typeof PublicEditorIdRoute + PublicGalleryIdRoute: typeof PublicGalleryIdRoute + PublicGalleryIndexRoute: typeof PublicGalleryIndexRoute } declare module '@tanstack/react-router' { @@ -58,6 +111,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof PublicIndexRouteImport parentRoute: typeof rootRouteImport } + '/_public/cart': { + id: '/_public/cart' + path: '/cart' + fullPath: '/cart' + preLoaderRoute: typeof PublicCartRouteImport + parentRoute: typeof rootRouteImport + } '/_guest/login': { id: '/_guest/login' path: '/login' @@ -65,12 +125,37 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof GuestLoginRouteImport parentRoute: typeof rootRouteImport } + '/_public/gallery/': { + id: '/_public/gallery/' + path: '/gallery' + fullPath: '/gallery' + preLoaderRoute: typeof PublicGalleryIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/_public/gallery/$id': { + id: '/_public/gallery/$id' + path: '/gallery/$id' + fullPath: '/gallery/$id' + preLoaderRoute: typeof PublicGalleryIdRouteImport + parentRoute: typeof rootRouteImport + } + '/_public/editor/$id': { + id: '/_public/editor/$id' + path: '/editor/$id' + fullPath: '/editor/$id' + preLoaderRoute: typeof PublicEditorIdRouteImport + parentRoute: typeof rootRouteImport + } } } const rootRouteChildren: RootRouteChildren = { GuestLoginRoute: GuestLoginRoute, + PublicCartRoute: PublicCartRoute, PublicIndexRoute: PublicIndexRoute, + PublicEditorIdRoute: PublicEditorIdRoute, + PublicGalleryIdRoute: PublicGalleryIdRoute, + PublicGalleryIndexRoute: PublicGalleryIndexRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/_public/cart.tsx b/src/routes/_public/cart.tsx new file mode 100644 index 0000000..e671d1c --- /dev/null +++ b/src/routes/_public/cart.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from '@tanstack/react-router'; +import Cart from '@/app/pages/public/cart'; + +export const Route = createFileRoute('/_public/cart')({ + head: () => ({ meta: [{ title: 'CustomCADs | Cart' }] }), + component: Cart, +}); diff --git a/src/routes/_public/editor.$id.tsx b/src/routes/_public/editor.$id.tsx new file mode 100644 index 0000000..13c1696 --- /dev/null +++ b/src/routes/_public/editor.$id.tsx @@ -0,0 +1,23 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { queries } from '@customcads/react-sdk'; +import Editor from '@/app/pages/public/editor'; + +export const Route = createFileRoute('/_public/editor/$id')({ + component: Editor, + loader: async ({ params, context: { queryClient } }) => { + const { data: product } = await queryClient.fetchQuery( + queries.products.gallery.single({ id: params.id }), + ); + + const { data: cad } = await queryClient.fetchQuery( + queries.cads.single({ id: product.cadId }), + ); + + return { + productId: product.id, + cadId: cad.id, + cadVolume: cad.volume, + }; + }, + head: () => ({ meta: [{ title: 'CustomCADs | Editor' }] }), +}); diff --git a/src/routes/_public/gallery/$id.tsx b/src/routes/_public/gallery/$id.tsx new file mode 100644 index 0000000..f58b5ae --- /dev/null +++ b/src/routes/_public/gallery/$id.tsx @@ -0,0 +1,15 @@ +import Product from '@/app/pages/public/product'; +import { queries } from '@customcads/react-sdk'; +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/_public/gallery/$id')({ + loader: async ({ params, context: { queryClient } }) => { + await queryClient.prefetchQuery( + queries.products.gallery.single({ id: params.id }), + ); + }, + component: Product, + head: ({ params }) => ({ + meta: [{ title: `CustomCADs | Product ${params.id}` }], + }), +}); diff --git a/src/routes/_public/gallery/index.tsx b/src/routes/_public/gallery/index.tsx new file mode 100644 index 0000000..0b7dfff --- /dev/null +++ b/src/routes/_public/gallery/index.tsx @@ -0,0 +1,55 @@ +import z from 'zod'; +import { createFileRoute } from '@tanstack/react-router'; +import { categoriesApi, queries } from '@customcads/react-sdk'; +import { OnlyParam } from '@/lib/utils/typescript'; +import * as limits from '@/app/constants/limits'; +import Gallery from '@/app/pages/public/gallery'; + +export const Route = createFileRoute('/_public/gallery/')({ + validateSearch: z.object({ + name: z.string().optional(), + categoryName: z.string().optional(), + sortingType: z.string().optional(), + sortingDirection: z.string().optional(), + page: z.number().optional(), + limit: z.number().optional(), + }), + component: Gallery, + loaderDeps: ({ search }) => ({ + name: search.name, + categoryName: search.categoryName, + sortingType: search.sortingType, + sortingDirection: search.sortingDirection, + page: search.page ?? 1, + limit: search.limit ?? limits.GALLERY.default, + }), + loader: async ({ deps, context }) => { + const { queryClient } = context; + + let category = null; + if (deps.categoryName) { + const { data } = await categoriesApi.single({ + type: 'by-name', + name: deps.categoryName, + }); + category = data; + } + + const { gallery } = queries.products; + const galleryQueryArgs: OnlyParam = { + name: deps.name, + categoryId: category?.id, + sortingType: deps.sortingType, + sortingDirection: deps.sortingDirection, + page: deps.page, + limit: deps.limit, + }; + await queryClient.prefetchQuery(gallery.all(galleryQueryArgs)); + + await queryClient.prefetchQuery(queries.categories.all); + await queryClient.prefetchQuery(queries.products.gallery.sortings); + + return { galleryQueryArgs }; + }, + head: () => ({ meta: [{ title: 'CustomCADs | Gallery' }] }), +}); 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'; diff --git a/src/types/react.ts b/src/types/react.ts index 2d9147b..2bb699d 100644 --- a/src/types/react.ts +++ b/src/types/react.ts @@ -1 +1 @@ -export type Children = { children: React.ReactNode }; +export type Children = { children?: React.ReactNode }; diff --git a/src/types/sorting.ts b/src/types/sorting.ts deleted file mode 100644 index 2afe3e5..0000000 --- a/src/types/sorting.ts +++ /dev/null @@ -1 +0,0 @@ -export type SortingDirection = 'ascending' | 'descending';