Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions app/components/cart/AddProductToast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ interface ToastProduct {
isOnSale?: boolean;
quantity: number;
url: string;
options: Array<{ name: string; value: string; hideValue?: boolean }>;
options: { name: string; value: string; hideValue?: boolean }[];
}

interface ProductAddedEntry {
Expand All @@ -25,7 +25,7 @@ interface ProductAddedEntry {

interface AddItemResponse {
data?: {
cart?: { items?: Array<{ id: string | number; product_id?: string | number }> };
cart?: { items?: { id: string | number; product_id?: string | number }[] };
events?: { 'twilight::api'?: { events?: { 'Product Added'?: ProductAddedEntry[] } } };
};
}
Expand Down Expand Up @@ -194,10 +194,10 @@ export function AddProductToast() {

// `Product Added` lists every cart line; `cart::item.added` names the product that was added.
useEffect(() => {
type EventBus = {
interface EventBus {
on?: (name: string, cb: (...args: unknown[]) => void) => void;
off?: (name: string, cb: (...args: unknown[]) => void) => void;
};
}
// The emitter the listeners were added to. Cleanup removes them from this
// one, not from whatever `window.salla.event` is by then — after an SDK
// re-init a fresh lookup would `off` a different emitter and leave these bound.
Expand Down
2 changes: 1 addition & 1 deletion app/components/cart/CartPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ function CartPageContent({ page }: CartPageProps) {
const cartId = cartIdQuery.data;

const cartQuery = useQuery({
...cart.queries.detail(cartId!),
...cart.queries.detail(cartId ?? 0),
enabled: !!cartId,
});
const cartData = cartQuery.data;
Expand Down
14 changes: 8 additions & 6 deletions app/components/common/BottomSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,20 +49,22 @@ export function BottomSheet({

useScrollLock(open);

if (open && !mounted) setMounted(true);

useEffect(() => {
if (open) {
setMounted(true);
return;
}
setEntered(false);
if (open) return;
const unmount = setTimeout(() => setMounted(false), TRANSITION_MS);
return () => clearTimeout(unmount);
}, [open]);

useEffect(() => {
if (!mounted || !open) return;
void sheetRef.current?.offsetWidth;
return afterNextPaint(() => setEntered(true));
const cancelEnter = afterNextPaint(() => setEntered(true));
return () => {
cancelEnter();
setEntered(false);
};
}, [mounted, open]);

// Move focus into the sheet on open, trap Tab, restore it to the trigger on close.
Expand Down
7 changes: 4 additions & 3 deletions app/components/home/EnhancedSlider.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { memo, useCallback, useEffect, useRef, useState, type RefObject } from 'react';
import { SallaSlider } from '@salla.sa/twilight-components-react/slider';
import { Link } from '@salla.sa/twilight-theme-engine/common';
import { useTranslation } from '@salla.sa/twilight-theme-engine/i18n';
import { parseSlideVideo, youtubeEmbedUrl } from '../../lib/slideVideo';
import { useMotionAllowed } from '../../hooks/useMotionAllowed';

Expand Down Expand Up @@ -99,6 +100,7 @@ const YOUTUBE_PLAYING = 1;
* the page says it is listening, and the frame answers with its state.
*/
function YouTubeBackground({ id }: { id: string }) {
const { t } = useTranslation();
const ref = useRef<HTMLIFrameElement>(null);
const [state, setState] = useState<'loading' | 'playing' | 'failed'>('loading');

Expand Down Expand Up @@ -151,13 +153,12 @@ function YouTubeBackground({ id }: { id: string }) {

if (state === 'failed') return null;
return (
// eslint-disable-next-line jsx-a11y/iframe-has-title -- decorative background video; the empty title is intentional
<iframe
ref={ref}
className="enhanced-slider__video enhanced-slider__video--embed"
data-state={state}
src={youtubeEmbedUrl(id, window.location.origin)}
title=""
src={youtubeEmbedUrl(id, window.origin)}
title={t('blocks.slider.background_video', 'Background video')}
loading="lazy"
allow="autoplay; encrypted-media"
referrerPolicy="strict-origin-when-cross-origin"
Expand Down
13 changes: 9 additions & 4 deletions app/components/home/FeaturedProducts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ interface FeaturedProductsSection {
/** Legacy (pre-custom-component) payload: tab name and pre-hydrated stubs. */
label?: string;
title?: string;
products?: Array<{ id?: unknown }>;
products?: { id?: unknown }[];
type?: PickerValue | PickerValue[];
category?: PickerValue | PickerValue[];
tag?: PickerValue | PickerValue[];
Expand Down Expand Up @@ -182,10 +182,15 @@ function TabProductGrid({
/** Highlighted product: used as-is when hydrated, otherwise fetched by id. */
function SpecialProduct({ value }: { value: FeaturedProductsData['main_product'] }) {
const ids = isFullProduct(value) ? [] : toIds(value);
const idsKey = ids.join(',');
const loader = useCallback(
() => product.list({ source: 'selected', sourceValue: ids, perPage: 1 }),
// eslint-disable-next-line react-hooks/exhaustive-deps, react-hooks/use-memo
[ids.join(',')]
() =>
product.list({
source: 'selected',
sourceValue: idsKey ? idsKey.split(',').map(Number) : [],
perPage: 1,
}),
[idsKey]
);

if (isFullProduct(value)) return <ProductCard product={value} layout="special" withShadow />;
Expand Down
7 changes: 2 additions & 5 deletions app/components/layout/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -167,10 +167,7 @@ export function Header({ navOpen: navOpenProp, onNavOpenChange }: HeaderProps =
// place when the bar goes `position: fixed`, since `__bar-outer` reserves
// the height).
useEffect(() => {
if (!stickyEnabled) {
setScrolled(false);
return;
}
if (!stickyEnabled) return;

if (!isDefaultLayout) {
const onScroll = () => setScrolled(window.scrollY > 4);
Expand Down Expand Up @@ -225,7 +222,7 @@ export function Header({ navOpen: navOpenProp, onNavOpenChange }: HeaderProps =
)}

<div className="site-header__bar-outer">
<div className={`site-header__bar${scrolled ? ' is-scrolled' : ''}`}>
<div className={`site-header__bar${stickyEnabled && scrolled ? ' is-scrolled' : ''}`}>
<div className="container site-header__bar-inner">
<div className="site-header__nav-toggle">
<button
Expand Down
6 changes: 2 additions & 4 deletions app/components/layout/HeaderMainMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,7 @@ function OneLineMenu({ items, moreTitle }: { items: MenuItem[]; moreTitle: strin
{/* Sizes the "More" item before it exists, styled like a root item. */}
<ul className="main-menu site-header__more-probe" aria-hidden="true">
<li ref={probeRef} className="s-menu-root-level s-menu-has-children">
{/* eslint-disable-next-line jsx-a11y/anchor-is-valid -- hidden, aria-hidden measuring probe; never navigable */}
<a tabIndex={-1}>
<a href="#main-menu-more" tabIndex={-1}>
<span>{moreTitle}</span>
</a>
</li>
Expand Down Expand Up @@ -235,8 +234,7 @@ function MenuEntry({ item, root }: { item: MenuItem; root: boolean }) {
{...(hasChildren ? { onMouseEnter: onOpen, onFocus: onOpen } : {})}
>
{isMore ? (
// eslint-disable-next-line jsx-a11y/anchor-is-valid -- "More" disclosure trigger; href="#" is kept as-is to preserve runtime behaviour
<a href="#" role="button" aria-haspopup="true" onClick={preventJump}>
<a href="#main-menu-more" role="button" aria-haspopup="true" onClick={preventJump}>
<span>{item.title}</span>
</a>
) : (
Expand Down
18 changes: 10 additions & 8 deletions app/components/layout/HeaderNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ interface HeaderNavProps {
const TRANSITION_MS = 320;

/** Nothing to subscribe to: `document.body` only ever differs between server and client. */
const subscribeNothing = () => () => {};
const subscribeNothing = () => () => undefined;

/** A menu item opens a sub-column only when its children are actually present. */
const hasChildren = (item: MenuItem): boolean =>
Expand Down Expand Up @@ -225,7 +225,10 @@ function NavHead({ canGoBack, onBack, onClose }: NavHeadProps) {
);
}

type Column = { parent: MenuItem | null; items: MenuItem[] };
interface Column {
parent: MenuItem | null;
items: MenuItem[];
}

interface NavColumnsProps {
columns: Column[];
Expand Down Expand Up @@ -366,13 +369,10 @@ function useDrawerTransition(open: boolean) {

useScrollLock(open);

if (open && !mounted) setMounted(true);

useEffect(() => {
if (open) {
setMounted(true);
return;
}
setEntered(false);
setItemsIn(false);
if (open) return;
const unmount = setTimeout(() => setMounted(false), TRANSITION_MS);
return () => clearTimeout(unmount);
}, [open]);
Expand All @@ -385,6 +385,8 @@ function useDrawerTransition(open: boolean) {
return () => {
cancelEnter();
clearTimeout(stagger);
setEntered(false);
setItemsIn(false);
};
}, [mounted, open]);

Expand Down
10 changes: 5 additions & 5 deletions app/components/layout/MobileAccountSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -347,12 +347,12 @@ export function MobileAccountSheet({
// replays from the top next time the sheet opens.
const [itemsIn, setItemsIn] = useState(false);
useEffect(() => {
if (!open) {
setItemsIn(false);
return;
}
if (!open) return;
const stagger = setTimeout(() => setItemsIn(true), ROWS_IN_DELAY_MS);
return () => clearTimeout(stagger);
return () => {
clearTimeout(stagger);
setItemsIn(false);
};
}, [open]);

// A link to the current page doesn't change the route, which is what closes the sheet otherwise.
Expand Down
81 changes: 34 additions & 47 deletions app/components/layout/MobileBottomBar.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react';
import type { ReactNode } from 'react';
import { useRouterState } from '@tanstack/react-router';
import { Link } from '@salla.sa/twilight-theme-engine/common';
Expand Down Expand Up @@ -27,16 +27,18 @@ interface MobileBottomBarProps {
onCloseSecondSheet?: () => void;
}

type CartSummary = { count?: number | string | null };
type CartSdk = {
interface CartSummary {
count?: number | string | null;
}
interface CartSdk {
storage?: { get?: <T>(key: string, fallback?: T) => T };
helpers?: { number?: (value: number) => string };
// Root on/off: onUpdated registers a wrapper that only offUpdated can remove.
event?: {
on?: (event: string, cb: (summary?: CartSummary) => void) => void;
off?: (event: string, cb: (summary?: CartSummary) => void) => void;
};
};
}

function getCartSdk(): CartSdk | undefined {
if (typeof window === 'undefined') return undefined;
Expand All @@ -52,18 +54,18 @@ function getCartSdk(): CartSdk | undefined {
stacked behind a backdrop) and the tapped tab's icon lights while its own
overlay is open.

The build's `no-manual-dom` check flags the `document.querySelector` calls
The build's `no-manual-dom` check flags the `document.body.querySelector` calls
below. They stay: the search modal and the user menu are rendered by Salla
web components elsewhere on the page, so there is no ref or React state
that reaches them. */

type Tab = 'home' | 'categories' | 'search' | 'cart' | 'account';

const searchModal = (): Element | null => document.querySelector('salla-modal.s-search-modal');
const searchModal = (): Element | null => document.body.querySelector('salla-modal.s-search-modal');

const userMenu = (): (HTMLElement & { opened?: boolean }) | null =>
(document.querySelector('salla-user-menu.header-action') ??
document.querySelector('salla-user-menu')) as (HTMLElement & { opened?: boolean }) | null;
(document.body.querySelector('salla-user-menu.header-action') ??
document.body.querySelector('salla-user-menu')) as (HTMLElement & { opened?: boolean }) | null;

/** Open the Salla search modal — the same event the header's search button fires. */
function openSearch() {
Expand Down Expand Up @@ -122,8 +124,8 @@ function activeTab(
return null;
}

const isSearchOpen = () => !!document.querySelector('salla-modal.s-search-modal[visible]');
const isMenuOpen = () => !!document.querySelector('.s-user-menu-toggler.opened');
const isSearchOpen = () => !!document.body.querySelector('salla-modal.s-search-modal[visible]');
const isMenuOpen = () => !!document.body.querySelector('.s-user-menu-toggler.opened');

/**
* Reflect a DOM condition into React state. Used for the two overlays the bar
Expand All @@ -143,43 +145,28 @@ const isMenuOpen = () => !!document.querySelector('.s-user-menu-toggler.opened')
* viewport.
*/
function useDomFlag(test: () => boolean, enabled: boolean): boolean {
const [on, setOn] = useState(false);

useEffect(() => {
if (!enabled) {
setOn(false);
return;
}

let current = test();
setOn(current);

// Only push a state update on an actual transition — the observer fires for
// every mutation under `<body>`, and a no-op `setOn` each time would trip
// React's act() warnings in tests and churn needlessly in the app.
const check = () => {
const next = test();
if (next === current) return;
current = next;
setOn(next);
};

const obs = new MutationObserver(check);
obs.observe(document.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['class', 'visible'],
});
document.addEventListener('theme::ready', check);

return () => {
obs.disconnect();
document.removeEventListener('theme::ready', check);
};
}, [test, enabled]);

return on;
const subscribe = useCallback(
(notify: () => void) => {
if (!enabled) return () => undefined;
// The store only re-renders when `test()` actually changes, so notifying on every
// mutation under `<body>` is cheap.
const obs = new MutationObserver(notify);
obs.observe(document.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['class', 'visible'],
});
document.addEventListener('theme::ready', notify);
return () => {
obs.disconnect();
document.removeEventListener('theme::ready', notify);
};
},
[enabled]
);
const getSnapshot = useCallback(() => enabled && test(), [test, enabled]);
return useSyncExternalStore(subscribe, getSnapshot, () => false);
}

const useSearchOpen = (enabled: boolean) => useDomFlag(isSearchOpen, enabled);
Expand Down
Loading
Loading