Navigation.tsx
1 // SPDX-FileCopyrightText: 2024 Mass Labs 2 // 3 // SPDX-License-Identifier: GPL-3.0-or-later 4 5 import { useEffect, useState } from "react"; 6 import { Link, useNavigate } from "@tanstack/react-router"; 7 import { useDisconnect } from "wagmi"; 8 9 import { Order, OrderedItem } from "@massmarket/schema"; 10 import { CodecValue } from "@massmarket/utils/codec"; 11 12 import { KeycardRole } from "../types.ts"; 13 import Cart from "./cart/Cart.tsx"; 14 import { useStateManager } from "../hooks/useStateManager.ts"; 15 import { useShopDetails } from "../hooks/useShopDetails.ts"; 16 import { useKeycard } from "../hooks/useKeycard.ts"; 17 import { useCurrentOrder } from "../hooks/useCurrentOrder.ts"; 18 19 const merchantMenu = [ 20 { 21 title: "Dashboard", 22 img: "menu-dashboard.svg", 23 href: "/merchant-dashboard", 24 }, 25 //TODO: href for orders, contact, share. 26 { title: "Manage Products", img: "menu-products.svg", href: "/listings" }, 27 { title: "Manage Orders", img: "menu-order.svg", href: "/orders" }, 28 { title: "Shop Settings", img: "menu-settings.svg", href: "/settings" }, 29 { title: "Disconnect", img: "menu-disconnect.svg" }, 30 ]; 31 32 const customerMenu = [ 33 { title: "Shop", img: "menu-products.svg", href: "/listings" }, 34 { 35 title: "Cart", 36 img: "menu-cart.svg", 37 href: `/cart`, 38 }, 39 { 40 title: "Contact", 41 img: "menu-contact.svg", 42 href: `/contact`, 43 }, 44 { 45 title: "Share", 46 img: "menu-share.svg", 47 href: `/share`, 48 }, 49 ]; 50 51 function Navigation() { 52 const [menuOpen, setMenuOpen] = useState<boolean>(false); 53 const [cartVisible, setCartVisible] = useState<boolean>(false); 54 const [cartSize, setCartSize] = useState<number>(0); 55 56 const navigate = useNavigate(); 57 const { shopDetails } = useShopDetails(); 58 const { stateManager } = useStateManager(); 59 const { currentOrder } = useCurrentOrder(); 60 const [keycard] = useKeycard(); 61 const { disconnect } = useDisconnect(); 62 const isMerchantView = keycard.role === KeycardRole.MERCHANT; 63 64 useEffect(() => { 65 // in the hook `useCurrentOrder`, we "reset" currentOrder for the states OrderState.Canceled and OrderState.Paid. 66 // this is done by setting it to null. 67 // TODO (@alp 2025-04-17): raise the question of using a sentinel value for "reset" orderIDs e.g. currentOrder = SENTINEL_ORDER 68 // 69 // NOTE(@alp 2025-04-17): this same bug (setting currentOrder = null) *might* be afflicting ListingDetail.tsx and 70 // the call to cancelAndRecreateOrder 71 if (!currentOrder) { 72 setCartSize(0); 73 return; 74 } 75 stateManager?.get(["Orders", currentOrder.ID]) 76 .then((o: CodecValue | undefined) => { 77 if (!o) { 78 throw new Error("No order found"); 79 } 80 const order = Order.fromCBOR(o); 81 // Getting number of items in order. 82 let cartSize = 0; 83 order.Items.forEach((item: OrderedItem) => (cartSize += item.Quantity)); 84 setCartSize(cartSize); 85 }); 86 }, [currentOrder]); 87 88 function onDisconnect() { 89 setMenuOpen(false); 90 localStorage.clear(); 91 disconnect(); 92 navigate({ 93 to: "/merchant-connect", 94 }); 95 } 96 97 function menuSwitch() { 98 setMenuOpen(!menuOpen); 99 cartVisible && setCartVisible(false); 100 } 101 102 function onCheckout() { 103 setCartVisible(false); 104 navigate({ 105 to: "/shipping", 106 search: (prev: Record<string, string>) => ({ 107 shopId: prev.shopId, 108 }), 109 }); 110 } 111 112 function renderMenuItems() { 113 const menuItems = isMerchantView ? merchantMenu : customerMenu; 114 return menuItems.map((opt, i) => { 115 if (opt.title === "Disconnect") { 116 return ( 117 <button 118 type="button" 119 style={{ backgroundColor: "transparent", padding: 0 }} 120 className="cursor-pointer" 121 key={i} 122 onClick={onDisconnect} 123 > 124 <div className="flex gap-3 items-center"> 125 <img 126 src={`/icons/${opt.img}`} 127 width={20} 128 height={20} 129 alt="menu-item" 130 className="w-5 h-5" 131 /> 132 <h2 className="font-normal">{opt.title}</h2> 133 <img 134 src="/icons/chevron-right.svg" 135 width={12} 136 height={12} 137 alt="chevron-right" 138 className="ml-auto w-3 h-3" 139 /> 140 </div> 141 </button> 142 ); 143 } 144 145 return ( 146 <div 147 data-testid={`menu-button-${opt.title}`} 148 key={i} 149 onClick={() => setMenuOpen(false)} 150 > 151 <Link 152 to={opt.href!} 153 key={opt.title} 154 search={(prev: Record<string, string>) => ({ 155 shopId: prev.shopId, 156 })} 157 > 158 <div className="flex gap-3 items-center"> 159 <img 160 src={`/icons/${opt.img}`} 161 width={20} 162 height={20} 163 alt="menu-item" 164 className="w-5 h-5" 165 /> 166 <h2 className="font-normal text-black">{opt.title}</h2> 167 <img 168 src="/icons/chevron-right.svg" 169 width={12} 170 height={12} 171 alt="chevron-right" 172 className="ml-auto w-3 h-3" 173 /> 174 </div> 175 </Link> 176 </div> 177 ); 178 }); 179 } 180 181 return ( 182 <section> 183 {(cartVisible || menuOpen) && ( 184 <span 185 className="fixed bg-black w-full h-full opacity-60 z-5" 186 onClick={() => { 187 cartVisible && setCartVisible(false); 188 menuOpen && setMenuOpen(false); 189 }} 190 /> 191 )} 192 <section 193 className={`bg-white flex justify-center z-10 relative`} 194 data-testid="navigation" 195 > 196 <section className="relative w-full text-base flex justify-between md:w-[800px] h-[56px] mr-3"> 197 <div 198 id="logo" 199 className="flex gap-2 cursor-pointer m-2" 200 onClick={() => { 201 navigate({ 202 to: isMerchantView ? "/merchant-dashboard" : "/listings", 203 search: (prev: Record<string, string>) => ({ 204 shopId: prev.shopId, 205 }), 206 }); 207 setMenuOpen(false); 208 setCartVisible(false); 209 }} 210 > 211 {shopDetails.profilePictureUrl 212 ? ( 213 <div className="overflow-hidden rounded-full w-10 h-10"> 214 <img 215 src={shopDetails.profilePictureUrl} 216 width={40} 217 height={40} 218 alt="profile-avatar" 219 className="w-10 h-10" 220 /> 221 </div> 222 ) 223 : ( 224 <img 225 src={`/icons/mass-labs-logo.svg`} 226 width={40} 227 height={40} 228 alt="mass-labs-logo" 229 className="w-10 h-10" 230 /> 231 )} 232 233 <h2 className="flex items-center">{shopDetails.name}</h2> 234 </div> 235 <section className="absolute right-0 flex"> 236 <div 237 id="menu" 238 className={`${ 239 cartVisible ? "invisible" : "visible" 240 } flex flex-col items-end`} 241 > 242 <button 243 onClick={menuSwitch} 244 style={{ 245 backgroundColor: menuOpen ? "#F3F3F3" : "transparent", 246 paddingLeft: 15, 247 paddingRight: 15, 248 }} 249 type="button" 250 className="self-end h-[56px] cursor-pointer" 251 > 252 <img 253 src={menuOpen 254 ? "/icons/close-icon.svg" 255 : "/icons/hamburger.svg"} 256 width={20} 257 height={20} 258 alt="menu-icon" 259 className="w-5 h-5" 260 /> 261 </button> 262 <div 263 className={`${menuOpen ? "hidden md:block z-10" : "hidden"}`} 264 > 265 <div className="fixed bg-background-gray w-full flex flex-col gap-5 rounded-b-lg p-5 w-fit static"> 266 {renderMenuItems()} 267 </div> 268 </div> 269 </div> 270 <div 271 id="cart" 272 className={`${ 273 menuOpen ? "invisible" : "visible" 274 } flex flex-col items-end relative`} 275 > 276 <button 277 type="button" 278 data-testid="cart-toggle" 279 className={`${ 280 isMerchantView ? "hidden" : "" 281 } self-end h-[56px]`} 282 style={{ 283 backgroundColor: cartVisible ? "#F3F3F3" : "transparent", 284 paddingLeft: 15, 285 paddingRight: 15, 286 }} 287 onClick={() => { 288 setCartVisible(!cartVisible); 289 menuOpen && setMenuOpen(false); 290 }} 291 > 292 <img 293 src={cartVisible 294 ? "/icons/close-icon.svg" 295 : "/icons/menu-cart.svg"} 296 width={20} 297 height={20} 298 alt="cart-icon" 299 className="w-5 h-5" 300 /> 301 <div 302 className={`${ 303 (!cartSize || cartVisible) ? "hidden" : "" 304 } bg-red-700 rounded-full absolute top-[10px] right-[7px] w-4 h-4 flex justify-center items-center`} 305 > 306 <p className="text-white text-[10px]">{cartSize}</p> 307 </div> 308 </button> 309 <div 310 className={`${cartVisible ? "hidden md:block z-10" : "hidden"}`} 311 > 312 <div 313 data-testid="desktop-cart" 314 className="fixed bg-background-gray w-full flex flex-col gap-5 rounded-b-lg p-5 static" 315 > 316 <h1>Cart</h1> 317 <Cart 318 onCheckout={onCheckout} 319 closeCart={() => setCartVisible(false)} 320 /> 321 </div> 322 </div> 323 </div> 324 </section> 325 </section> 326 </section> 327 <section id="mobile-menu" className="md:hidden absolute z-10"> 328 {menuOpen 329 ? ( 330 <section> 331 <div className="fixed bg-background-gray w-full flex flex-col gap-5 rounded-b-lg p-5"> 332 {renderMenuItems()} 333 </div> 334 </section> 335 ) 336 : null} 337 {cartVisible 338 ? ( 339 <section> 340 <div className="fixed bg-background-gray w-full flex flex-col gap-5 rounded-b-lg p-5"> 341 <h1>Cart</h1> 342 <Cart 343 onCheckout={onCheckout} 344 closeCart={() => setCartVisible(false)} 345 /> 346 </div> 347 </section> 348 ) 349 : null} 350 </section> 351 </section> 352 ); 353 } 354 355 export default Navigation;