diff --git a/apps/frontend/src/app/page.tsx b/apps/frontend/src/app/page.tsx index e223ffc..9eee137 100644 --- a/apps/frontend/src/app/page.tsx +++ b/apps/frontend/src/app/page.tsx @@ -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) diff --git a/apps/frontend/src/components/CartPanel.tsx b/apps/frontend/src/components/CartPanel.tsx index 1c2d8dc..235456f 100644 --- a/apps/frontend/src/components/CartPanel.tsx +++ b/apps/frontend/src/components/CartPanel.tsx @@ -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'; @@ -61,7 +61,7 @@ export function CartPanel({ onClose }: CartPanelProps) { )} - 0} /> + 0} /> ); diff --git a/apps/frontend/src/components/ChefModal.tsx b/apps/frontend/src/components/ChefModal.tsx index caf009e..4ecf3cb 100644 --- a/apps/frontend/src/components/ChefModal.tsx +++ b/apps/frontend/src/components/ChefModal.tsx @@ -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(chef.restaurants ?? []); useEffect(() => { diff --git a/apps/frontend/src/components/ChefsGrid.tsx b/apps/frontend/src/components/ChefsGrid.tsx index e9ec189..eb59fd1 100644 --- a/apps/frontend/src/components/ChefsGrid.tsx +++ b/apps/frontend/src/components/ChefsGrid.tsx @@ -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'; @@ -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() { @@ -18,9 +20,12 @@ export function ChefsGrid() { const [activeTab, setActiveTab] = useState('all'); const [highlightId, setHighlightId] = useState(null); const [selectedChef, setSelectedChef] = useState(null); + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); const gridRef = useRef(null); const gridVisible = useIntersectionObserver(gridRef); const tabsRef = useRef(null); + const sentinelRef = useRef(null); + const highlightPendingRef = useRef(false); useTabIndicator(tabsRef, activeTab); useEffect(() => { @@ -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; const scrollTimer = setTimeout(() => { @@ -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 && ( @@ -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 => ( ))} + {visibleCount < filtered.length && ( + )} diff --git a/apps/frontend/src/components/IconsLegend.tsx b/apps/frontend/src/components/IconsLegend.tsx index f7d2847..3077161 100644 --- a/apps/frontend/src/components/IconsLegend.tsx +++ b/apps/frontend/src/components/IconsLegend.tsx @@ -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(null); + const isVisible = useIntersectionObserver(ref); + return ( -
+

{TEXT.home.iconsTitle}

    {TEXT.icons.map(icon => ( diff --git a/apps/frontend/src/components/MobileChefSection.tsx b/apps/frontend/src/components/MobileChefSection.tsx index 2d9848d..e4e9c17 100644 --- a/apps/frontend/src/components/MobileChefSection.tsx +++ b/apps/frontend/src/components/MobileChefSection.tsx @@ -1,8 +1,12 @@ +'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; @@ -10,8 +14,14 @@ interface Props { } export function MobileChefSection({ chef, restaurants }: Props) { + const ref = useRef(null); + const isVisible = useIntersectionObserver(ref); + return ( -
    +

    {TEXT.chefOfTheWeek.sectionTitle}

    (null); + const isVisible = useIntersectionObserver(ref); + return ( -
    +

    {title}

    {children} {linkLabel && linkHref && ( diff --git a/apps/frontend/src/components/RestaurantsGrid.tsx b/apps/frontend/src/components/RestaurantsGrid.tsx index 04e2ce0..2853cdc 100644 --- a/apps/frontend/src/components/RestaurantsGrid.tsx +++ b/apps/frontend/src/components/RestaurantsGrid.tsx @@ -173,7 +173,7 @@ export function RestaurantsGrid() { }, { threshold: 0.1 }); observer.observe(sentinel); return () => observer.disconnect(); - }, [filtered, loadMore]); + }, [loadMore]); function clearAllFilters() { setSelectedRatings(new Set()); diff --git a/apps/frontend/src/context/CartContext.tsx b/apps/frontend/src/context/CartContext.tsx index c702f33..3c2aa19 100644 --- a/apps/frontend/src/context/CartContext.tsx +++ b/apps/frontend/src/context/CartContext.tsx @@ -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]) ); } @@ -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) { @@ -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); diff --git a/apps/frontend/src/styles/basics/_base.scss b/apps/frontend/src/styles/basics/_base.scss index 2acb093..2484029 100644 --- a/apps/frontend/src/styles/basics/_base.scss +++ b/apps/frontend/src/styles/basics/_base.scss @@ -53,3 +53,8 @@ ul { margin: 0; padding: 0; } + +.epicure-scroll-sentinel { + height: 1px; + width: 100%; +} diff --git a/apps/frontend/src/styles/components/_cart-item.scss b/apps/frontend/src/styles/components/_cart-item.scss index bf5a344..cd0ddaa 100644 --- a/apps/frontend/src/styles/components/_cart-item.scss +++ b/apps/frontend/src/styles/components/_cart-item.scss @@ -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 { @@ -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; @@ -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; } } diff --git a/apps/frontend/src/styles/components/_chef-week.scss b/apps/frontend/src/styles/components/_chef-week.scss index c113612..6b715d3 100644 --- a/apps/frontend/src/styles/components/_chef-week.scss +++ b/apps/frontend/src/styles/components/_chef-week.scss @@ -20,6 +20,7 @@ position: relative; width: 100%; margin-bottom: 1.25rem; + overflow: hidden; } &__photo { @@ -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 { diff --git a/apps/frontend/src/styles/components/_dish-card.scss b/apps/frontend/src/styles/components/_dish-card.scss index 1f1bdcc..dbdf66f 100644 --- a/apps/frontend/src/styles/components/_dish-card.scss +++ b/apps/frontend/src/styles/components/_dish-card.scss @@ -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; } @@ -13,6 +19,7 @@ aspect-ratio: 1; object-fit: cover; display: block; + transition: transform $duration-base $ease-out; } &__type-icon { @@ -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; diff --git a/apps/frontend/src/styles/components/_dish-grid.scss b/apps/frontend/src/styles/components/_dish-grid.scss index 2fc9320..e94dac5 100644 --- a/apps/frontend/src/styles/components/_dish-grid.scss +++ b/apps/frontend/src/styles/components/_dish-grid.scss @@ -13,7 +13,7 @@ @include for-normal-layout { grid-template-columns: repeat(4, 272px); - grid-auto-rows: 413px; + grid-auto-rows: 460px; justify-content: center; gap: 2rem; width: 1184px; @@ -23,6 +23,13 @@ } } +// ── Card entrance animation ─────────────────────────────────────── +.epicure-dish-grid .epicure-dish-card { + animation: epicure-fade-in 250ms $ease-out both; + + @include reduced-motion { animation: none; } +} + // ── Dish card overrides (restaurant detail context only) ────────── // Scoped under .epicure-dish-grid so homepage DishCard is unaffected. // Resets desktop _dish-card.scss overrides (690px height, large text, @@ -100,7 +107,7 @@ // ── Desktop overrides ──────────────────────────────────────────── @include for-normal-layout { - height: auto; + height: 100%; border-radius: 4px; &__image { @@ -114,12 +121,13 @@ padding: 0.875rem 1rem 1rem; display: flex; flex-direction: column; + flex: 1; } &__name { font-size: 2rem; font-weight: 400; - min-height: auto; + min-height: 4.8rem; order: unset; } diff --git a/apps/frontend/src/styles/components/_mobile-section.scss b/apps/frontend/src/styles/components/_mobile-section.scss index 6179150..c58539e 100644 --- a/apps/frontend/src/styles/components/_mobile-section.scss +++ b/apps/frontend/src/styles/components/_mobile-section.scss @@ -85,6 +85,21 @@ &:hover { opacity: 0.7; } } + opacity: 0; + transform: translateY(12px); + transition: opacity $duration-slow $ease-out, transform $duration-slow $ease-out; + + &--visible { + opacity: 1; + transform: none; + } + + @include reduced-motion { + opacity: 1; + transform: none; + transition: none; + } + @include for-normal-layout { display: none; } } @@ -119,11 +134,25 @@ flex-direction: column; align-items: center; gap: 2rem; + opacity: 0; + transform: translateY(12px); + transition: opacity $duration-slow $ease-out, transform $duration-slow $ease-out; + + &:nth-child(2) { transition-delay: 80ms; } + &:nth-child(3) { transition-delay: 160ms; } + + &:hover .epicure-icons-legend__icon { transform: scale(1.18); } + } + + &--visible &__item { + opacity: 1; + transform: none; } &__icon { width: 56px; height: 56px; + transition: transform 200ms $ease-out; } &__label { @@ -133,5 +162,27 @@ letter-spacing: 1.97px; } + @include reduced-motion { + &__item { opacity: 1; transform: none; transition: none; } + &__icon { transition: none; } + } + @include for-normal-layout { display: none; } } + +.epicure-chef-week { + opacity: 0; + transform: translateY(12px); + transition: opacity $duration-slow $ease-out, transform $duration-slow $ease-out; + + &--visible { + opacity: 1; + transform: none; + } + + @include reduced-motion { + opacity: 1; + transform: none; + transition: none; + } +} diff --git a/apps/frontend/src/styles/components/_sign-in-modal.scss b/apps/frontend/src/styles/components/_sign-in-modal.scss index 2d4f36a..731a49c 100644 --- a/apps/frontend/src/styles/components/_sign-in-modal.scss +++ b/apps/frontend/src/styles/components/_sign-in-modal.scss @@ -140,7 +140,7 @@ } .epicure-signin-modal { - top: 173px; + top: 110px; left: 50%; right: auto; transform: translateX(-50%); @@ -158,7 +158,7 @@ &__close-desktop { display: block; position: fixed; - top: 144px; + top: 81px; left: calc(50% - 286px); z-index: $z-panel-top; background: none; diff --git a/apps/frontend/src/styles/pages/_chefs.scss b/apps/frontend/src/styles/pages/_chefs.scss index f623bbc..7ac1d0b 100644 --- a/apps/frontend/src/styles/pages/_chefs.scss +++ b/apps/frontend/src/styles/pages/_chefs.scss @@ -67,6 +67,12 @@ gap: 1rem; padding: 32px $layout-gutter-mobile 56px; + .epicure-chef-grid__item { + animation: epicure-fade-in 250ms $ease-out both; + + @include reduced-motion { animation: none; } + } + @include for-normal-layout { grid-template-columns: repeat(3, 379px); gap: 1.5rem; diff --git a/apps/frontend/src/styles/pages/_home.scss b/apps/frontend/src/styles/pages/_home.scss index a02c99a..6322dc0 100644 --- a/apps/frontend/src/styles/pages/_home.scss +++ b/apps/frontend/src/styles/pages/_home.scss @@ -192,10 +192,10 @@ .epicure-dish-card__restaurant { order: 2; } .epicure-dish-card__type-icon { order: 3; } .epicure-dish-card__description { order: 4; } - .epicure-dish-card__price-row { order: 5; margin-top: 0; } + .epicure-dish-card__price-row { order: 5; margin-top: auto; } .epicure-dish-card__info { - justify-content: space-between; + justify-content: flex-start; } } } @@ -224,7 +224,10 @@ align-items: start; } - &__photo-col { position: relative; } + &__photo-col { + position: relative; + overflow: hidden; + } &__photo { width: 100%; @@ -232,6 +235,16 @@ object-fit: cover; display: block; filter: grayscale(100%); + transition: filter $duration-base $ease-out, transform $duration-base $ease-out; + } + + &__photo-col:hover &__photo { + filter: grayscale(0%); + transform: scale(1.03); + } + + @include reduced-motion { + &__photo { transition: none; } } &__name-bar { diff --git a/apps/frontend/src/styles/pages/_restaurants.scss b/apps/frontend/src/styles/pages/_restaurants.scss index 9a5b272..77aa312 100644 --- a/apps/frontend/src/styles/pages/_restaurants.scss +++ b/apps/frontend/src/styles/pages/_restaurants.scss @@ -62,6 +62,12 @@ gap: 1rem; padding: 32px 20px 56px; + .epicure-restaurant-card { + animation: epicure-fade-in 250ms $ease-out both; + + @include reduced-motion { animation: none; } + } + .epicure-restaurant-card__image { aspect-ratio: 334 / 207; height: auto; @@ -216,7 +222,3 @@ @include reduced-motion { transition: none; } } -.epicure-scroll-sentinel { - height: 1px; - width: 100%; -}