Maged Faiz

Case study · Amigos

Building Amigos: A Cozy Restaurant Site in Juba

Amigos Restaurant in Juba: Savor the blend of local and global flavors at a friendly spot where every meal is a celebration of community.

Filed
Mar, 2023 · completed
Stack
React / Firebase / Vite / Netlify / JavaScript / Sass
Live
netlify.app

01The vision

Amigos is a restaurant in Juba that serves fusion food, local dishes next to international ones, in a room that goes for warm and unfussy. The food was doing the talking, but there was no website to match it. The brief pointed at clean, image-led restaurant sites like La Barraca, and the goal was a site that felt as welcoming online as the place does in person.

This was an early solo build for me, so some of the choices were about learning as much as the result. I used it to get properly comfortable with Sass, and to write my own scroll animations instead of reaching for a library.

02Learning Sass for real

This was the first project where I used Sass instead of plain CSS. What stuck with me was how much repetition disappears once you have variables and mixins. A layout pattern I was writing over and over, a flex container with a height and an alignment, collapsed into one mixin I called everywhere. Even the offset corner borders on the cards turned into a single reusable piece instead of copy-pasted pseudo-elements.

Sass building blocks

03Scroll animations without a library

Plenty of libraries will animate elements as they scroll into view, but I wanted to understand the browser feature underneath instead of handing it to a dependency. That feature is the Intersection Observer API, which tells you when an element enters the viewport without the cost of listening to every scroll event. I wrapped it in one small hook. You pass it some refs, a threshold, and a class name, and it adds the class when the element shows up. A flag decides whether the animation plays once or replays each time the element comes back into view.

src/hooks/useIntersectionObserver.js
const useIntersectionObserver = (
  refs,
  threshold,
  animationClass,
  once = true
) => {
  const onVisibilityChanged = useCallback(
    (entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          entry.target.classList.add(animationClass);
        } else if (!once) {
          entry.target.classList.remove(animationClass);
        }
      });
    },
    [animationClass, once]
  );

  useEffect(() => {
    if (refs.length === 0) return;

    const options = {
      threshold: threshold,
    };
    const observer = new IntersectionObserver(onVisibilityChanged, options);

    refs.forEach((el) => observer.observe(el?.current));

    return () => {
      refs.forEach((el) => {
        if (el.current) {
          observer.unobserve(el.current);
        }
      });
    };
  }, [refs, onVisibilityChanged]);
};

The menu showcase is where that hook actually paid off. I pass its six image refs to useIntersectionObserver with a slide-up class, then stagger each one with an animationDelay tied to its index, so the dishes do not all drop in at once. They cascade down the grid as you reach them. The delay sits behind a width check, since the staggered reveal only makes sense on wider screens.

src/pages/menu/Menu.jsx
useIntersectionObserver(refs, 0.15, 'slide-up');

// ...

{menuShowcase.map((item, index) => {
  return (
    <div
      ref={item.ref}
      key={index}
      className='item'
      style={width >= 768 ? { animationDelay: `${index * 0.3}s` } : {}}
    >
      <img
        src={item.img}
        alt={item.dish}
        title={item.dish}
        loading='eager'
      />

      <div className='item-info'>
        <h2>{item.dish}</h2>
        <h3>{item.category}</h3>
      </div>
    </div>
  );
})}

04The video header, honestly

The homepage was always going to open on full-screen video, because food in motion does more for a restaurant than any hero image. The original plan was to film the space itself, but Amigos was mid-renovation at the time, so we switched to the food and filmed a classic pizza from dough to finish. The full four-minute cut looked great and was far too heavy to sit at the top of a page, so most of the work happened before I wrote any code. I compressed it hard and cut it into five short, muted, looping clips, small enough to autoplay without making the page crawl.

In the page itself the header just picks one of those clips at random on each load, so the site never opens the same way twice. Small touch, but it makes the place feel a little more alive when you come back.

src/components/header/Header.jsx
const Header = () => {
  const [selectedVid, setSelectedVid] = useState(null);

  useEffect(() => {
    const randomNum = Math.floor(Math.random() * vids.length);
    setSelectedVid(vids[randomNum]);
  }, []);

  return (
    <header>
      <div className='bg-vid'>
        {selectedVid && (
          <video autoPlay={true} loop={true} muted={true} playsInline={true}>
            <source src={selectedVid} type='video/mp4' />
          </video>
        )}
      </div>
      {/* ... */}
    </header>
  );
};

05Scope, and a pragmatic call

The project did not start as a brochure site. The first plan had a full ordering system and a delivery app, but the budget tightened and we scaled back to a showcase. I could have moved the content onto a proper CMS, but the timeline was short, so I stuck with the Firebase setup we already had. Not the most elegant choice for a content-only site, but it got a finished site out the door on schedule.

The other lesson was about being found. A client-side React app gives search engines very little to read, so I added react-helmet-async to manage the document head and set the title, description, and social-share tags per page. Not glamorous, but it is the difference between a site that turns up in a search and one that stays invisible.

Under the hood it is a React app built with Vite, with Firebase (Firestore) serving the menu and events data and EmailJS handling the contact form, so there was no server to run. Even the prices come out of Firestore as plain numbers and get formatted into South Sudanese pounds with Intl.NumberFormat, instead of gluing currency strings together by hand.

06What I took from it

Amigos has since closed, but the site is still online, a small archive of a place a lot of people in Juba were fond of. As an early solo project it left me with a couple of habits I still lean on. I organize my styles so I stop repeating myself, and I check for the platform feature before I install the package that wraps it. It also taught me to expect the brief to move, because on this one it moved more than once.

Building Amigos: A Cozy Restaurant Site in Juba