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
2 changes: 1 addition & 1 deletion apps/frontend/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default async function HomePage() {
const popularRestaurants = restaurants.filter(r => r.isPopular);
const weeklyChef = chefs.find(c => c.chefOfTheWeek);
const chefRestaurantIds = new Set(weeklyChef?.restaurants?.map(r => r.id) ?? []);
const chefRestaurants = weeklyChef?.restaurants != null
const chefRestaurants = chefRestaurantIds.size > 0
? restaurants.filter(r => chefRestaurantIds.has(r.id))
: weeklyChef
? restaurants.filter(r => r.chef?.id === weeklyChef.id)
Expand Down
4 changes: 2 additions & 2 deletions apps/frontend/src/components/CartPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ interface CartPanelProps {
}

export function CartPanel({ onClose }: CartPanelProps) {
const { cartItems, restaurantName, totalPrice, comment, setComment } = useCart();
const { cartItems, restaurantName, totalPrice, totalItems, comment, setComment } = useCart();

useEffect(() => {
document.body.style.overflow = 'hidden';
Expand Down Expand Up @@ -61,7 +61,7 @@ export function CartPanel({ onClose }: CartPanelProps) {
</div>
</div>
)}
<CartFooter totalPrice={totalPrice} hasItems={cartItems.length > 0} />
<CartFooter totalPrice={totalPrice} hasItems={totalItems > 0} />
</div>
</>
);
Expand Down
2 changes: 1 addition & 1 deletion apps/frontend/src/components/ChefModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ interface ChefModalProps {
}

export function ChefModal({ chef, onClose }: ChefModalProps) {
const hasPopulated = chef.restaurants !== undefined;
const hasPopulated = chef.restaurants !== undefined && chef.restaurants.length > 0;
const [restaurants, setRestaurants] = useState<ChefRestaurant[]>(chef.restaurants ?? []);

useEffect(() => {
Expand Down
49 changes: 39 additions & 10 deletions apps/frontend/src/components/ChefsGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useState, useEffect, useMemo, useRef } from 'react';
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import type { Chef } from '@org/shared-types';
import { ChefCard } from '@org/ui-components';
import { TEXT } from '../lib/text';
Expand All @@ -10,6 +10,8 @@ import { ChefModal } from './ChefModal';
import { useIntersectionObserver } from '../hooks/useIntersectionObserver';
import { useTabIndicator } from '../hooks/useTabIndicator';

const PAGE_SIZE = 6;

type ChefTab = (typeof TEXT.chefsGrid.tabs)[number]['id'];

export function ChefsGrid() {
Expand All @@ -18,9 +20,12 @@ export function ChefsGrid() {
const [activeTab, setActiveTab] = useState<ChefTab>('all');
const [highlightId, setHighlightId] = useState<number | null>(null);
const [selectedChef, setSelectedChef] = useState<Chef | null>(null);
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const gridRef = useRef<HTMLDivElement>(null);
const gridVisible = useIntersectionObserver(gridRef);
const tabsRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
const highlightPendingRef = useRef(false);
useTabIndicator(tabsRef, activeTab);

useEffect(() => {
Expand All @@ -45,10 +50,37 @@ export function ChefsGrid() {
.finally(() => setLoading(false));
}, []);

const filtered = useMemo(() => {
if (activeTab === 'new') return [...chefs].sort((a, b) => b.id - a.id);
if (activeTab === 'most-viewed')
return chefs.filter(c => c.chefOfTheWeek);
return chefs;
}, [chefs, activeTab]);

useEffect(() => {
if (highlightPendingRef.current) return;
setVisibleCount(PAGE_SIZE);
}, [filtered]);

const loadMore = useCallback(() => setVisibleCount(prev => prev + PAGE_SIZE), []);

useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(entries => {
if (entries[0].isIntersecting) loadMore();
}, { threshold: 0 });
observer.observe(sentinel);
return () => observer.disconnect();
}, [loadMore]);

useEffect(() => {
if (!highlightId || chefs.length === 0) return;
// Switch to 'all' so the chef card is guaranteed to be in the DOM
// Switch to 'all' and show all chefs so the target card is in the DOM.
// Set the pending flag first so the [filtered] reset effect skips the PAGE_SIZE reset.
highlightPendingRef.current = true;
setActiveTab('all');
setVisibleCount(chefs.length);
const isMobile = window.matchMedia('(max-width: 767px)').matches;
let modalTimer: ReturnType<typeof setTimeout>;
const scrollTimer = setTimeout(() => {
Expand All @@ -59,18 +91,12 @@ export function ChefsGrid() {
modalTimer = setTimeout(() => {
if (chef) setSelectedChef(chef);
setHighlightId(null);
highlightPendingRef.current = false;
}, isMobile ? 1200 : 500);
}, 1000);
return () => { clearTimeout(scrollTimer); clearTimeout(modalTimer); };
}, [highlightId, chefs]);

const filtered = useMemo(() => {
if (activeTab === 'new') return [...chefs].sort((a, b) => b.id - a.id);
if (activeTab === 'most-viewed')
return chefs.filter(c => c.chefOfTheWeek);
return chefs;
}, [chefs, activeTab]);

return (
<>
{selectedChef && (
Expand Down Expand Up @@ -107,7 +133,7 @@ export function ChefsGrid() {
ref={gridRef}
className={`epicure-chef-grid${gridVisible ? ' epicure-chef-grid--visible' : ''}`}
>
{filtered.map(chef => (
{filtered.slice(0, visibleCount).map(chef => (
<button
key={chef.id}
id={`chef-${chef.id}`}
Expand All @@ -119,6 +145,9 @@ export function ChefsGrid() {
<ChefCard chef={chef} imageUrl={strapiImageUrl(chef.image?.url)} />
</button>
))}
{visibleCount < filtered.length && (
<div ref={sentinelRef} className="epicure-scroll-sentinel" aria-hidden="true" />
)}
</div>
)}
</>
Expand Down
9 changes: 8 additions & 1 deletion apps/frontend/src/components/IconsLegend.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
'use client';

import { useRef } from 'react';
import { TEXT } from '../lib/text';
import { useIntersectionObserver } from '../hooks/useIntersectionObserver';

export function IconsLegend() {
const ref = useRef<HTMLDivElement>(null);
const isVisible = useIntersectionObserver(ref);

return (
<div className="epicure-icons-legend">
<div ref={ref} className={`epicure-icons-legend${isVisible ? ' epicure-icons-legend--visible' : ''}`}>
<h2 className="epicure-icons-legend__title">{TEXT.home.iconsTitle}</h2>
<ul className="epicure-icons-legend__list">
{TEXT.icons.map(icon => (
Expand Down
12 changes: 11 additions & 1 deletion apps/frontend/src/components/MobileChefSection.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
'use client';

import { useRef } from 'react';
import { RestaurantCard } from '@org/ui-components';
import type { Chef, Restaurant } from '@org/shared-types';
import { strapiImageUrl } from '../lib/api';
import { TEXT } from '../lib/text';
import { Carousel } from './Carousel';
import { useIntersectionObserver } from '../hooks/useIntersectionObserver';

interface Props {
chef: Chef;
restaurants: Restaurant[];
}

export function MobileChefSection({ chef, restaurants }: Props) {
const ref = useRef<HTMLElement>(null);
const isVisible = useIntersectionObserver(ref);

return (
<section className="epicure-chef-week">
<section
ref={ref}
className={`epicure-chef-week${isVisible ? ' epicure-chef-week--visible' : ''}`}
>
<h2 className="epicure-chef-week__title">{TEXT.chefOfTheWeek.sectionTitle}</h2>
<div className="epicure-chef-week__photo-wrap">
<img
Expand Down
13 changes: 11 additions & 2 deletions apps/frontend/src/components/MobileSection.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
'use client';

import Link from 'next/link';
import { type ReactNode } from 'react';
import { useRef, type ReactNode } from 'react';
import { Carousel } from './Carousel';
import { useIntersectionObserver } from '../hooks/useIntersectionObserver';

interface MobileSectionProps {
title: string;
Expand All @@ -10,8 +13,14 @@ interface MobileSectionProps {
}

export function MobileSection({ title, linkLabel, linkHref, children }: MobileSectionProps) {
const ref = useRef<HTMLElement>(null);
const isVisible = useIntersectionObserver(ref);

return (
<section className="epicure-mobile-section">
<section
ref={ref}
className={`epicure-mobile-section${isVisible ? ' epicure-mobile-section--visible' : ''}`}
>
<h2 className="epicure-mobile-section__title">{title}</h2>
<Carousel className="epicure-mobile-section__row">{children}</Carousel>
{linkLabel && linkHref && (
Expand Down
2 changes: 1 addition & 1 deletion apps/frontend/src/components/RestaurantsGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export function RestaurantsGrid() {
}, { threshold: 0.1 });
observer.observe(sentinel);
return () => observer.disconnect();
}, [filtered, loadMore]);
}, [loadMore]);

function clearAllFilters() {
setSelectedRatings(new Set());
Expand Down
18 changes: 6 additions & 12 deletions apps/frontend/src/context/CartContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,13 @@ function isValidCartState(val: unknown): val is CartState {
}

function itemsMatch(a: CartItem, b: CartItem): boolean {
const changesA = [...a.selectedChanges].sort();
const changesB = [...b.selectedChanges].sort();
return (
a.dish.id === b.dish.id &&
a.selectedSide === b.selectedSide &&
a.selectedChanges.length === b.selectedChanges.length &&
a.selectedChanges.every((c, i) => c === b.selectedChanges[i])
changesA.length === changesB.length &&
changesA.every((c, i) => c === changesB[i])
);
}

Expand Down Expand Up @@ -114,15 +116,7 @@ export function CartProvider({ children }: { children: ReactNode }) {
}

function confirmRemove(item: CartItem) {
setState(prev => {
const updated = prev.cartItems.filter(c => !itemsMatch(c, item));
return {
...prev,
cartItems: updated,
restaurantId: updated.length ? prev.restaurantId : null,
restaurantName: updated.length ? prev.restaurantName : null,
};
});
removeFromCart(item);
}

function cancelRemove(item: CartItem) {
Expand All @@ -135,7 +129,7 @@ export function CartProvider({ children }: { children: ReactNode }) {
}

function conflictsWithCart(incomingRestaurantId: number): boolean {
return state.cartItems.some(c => !c.pendingRemove) && state.restaurantId !== incomingRestaurantId;
return state.cartItems.length > 0 && state.restaurantId !== incomingRestaurantId;
}

const committedItems = state.cartItems.filter(c => !c.pendingRemove);
Expand Down
5 changes: 5 additions & 0 deletions apps/frontend/src/styles/basics/_base.scss
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,8 @@ ul {
margin: 0;
padding: 0;
}

.epicure-scroll-sentinel {
height: 1px;
width: 100%;
}
35 changes: 21 additions & 14 deletions apps/frontend/src/styles/components/_cart-item.scss
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,15 @@
&--confirm-remove {
animation: epicure-cart-shake 0.35s ease-out;
@include reduced-motion { animation: none; }
height: auto;
align-items: flex-start;

.epicure-cart-item__body {
height: auto;
overflow: visible;
}

.epicure-cart-item__price-row { display: none; }
}

&__confirm-remove {
Expand Down Expand Up @@ -140,20 +149,6 @@
margin-bottom: 0.125rem;
}

&__delete {
display: flex;
align-items: center;
justify-content: center;
align-self: center;
background: none;
border: none;
cursor: pointer;
padding: 0.5rem;
flex-shrink: 0;
opacity: 0.55;
&:hover { opacity: 1; }
}

&__price-row {
display: flex;
align-items: center;
Expand Down Expand Up @@ -222,4 +217,16 @@
.epicure-cart-item__name { font-size: 1rem; }
.epicure-cart-item__meta { font-size: 0.8125rem; white-space: normal; }
.epicure-cart-item__price-val { font-size: 0.9375rem; }

.epicure-cart-item--confirm-remove {
height: auto;
align-items: flex-start;
}

.epicure-cart-item--confirm-remove .epicure-cart-item__body {
height: auto;
overflow: visible;
}

.epicure-cart-item--confirm-remove .epicure-cart-item__price-row { display: none; }
}
11 changes: 11 additions & 0 deletions apps/frontend/src/styles/components/_chef-week.scss
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
position: relative;
width: 100%;
margin-bottom: 1.25rem;
overflow: hidden;
}

&__photo {
Expand All @@ -28,6 +29,16 @@
object-fit: cover;
display: block;
filter: grayscale(100%);
transition: filter $duration-base $ease-out, transform $duration-base $ease-out;
}

&__photo-wrap:hover &__photo {
filter: grayscale(0%);
transform: scale(1.03);
}

@include reduced-motion {
&__photo { transition: none; }
}

&__name-bar {
Expand Down
17 changes: 15 additions & 2 deletions apps/frontend/src/styles/components/_dish-card.scss
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
background: $white;
border-radius: 4px;
overflow: hidden;
transition: box-shadow 200ms;

@include reduced-motion {
transition: none;
&__image { transition: none; }
}

&__image-wrap { position: relative; }

Expand All @@ -13,6 +19,7 @@
aspect-ratio: 1;
object-fit: cover;
display: block;
transition: transform $duration-base $ease-out;
}

&__type-icon {
Expand Down Expand Up @@ -89,14 +96,20 @@
color: inherit;
cursor: pointer;

&:hover { opacity: 0.92; }
&:hover .epicure-dish-card { box-shadow: 0 6px 20px $shadow-subtle; }
&:hover .epicure-dish-card__image { transform: scale(1.04); }

@include reduced-motion {
.epicure-dish-card { transition: none; }
.epicure-dish-card__image { transition: none; }
}
}

@include for-normal-layout {
.epicure-dish-card {
display: flex;
flex-direction: column;
height: 690px;
height: 650px;

&__image-wrap {
flex-shrink: 0;
Expand Down
Loading
Loading