Maged Faiz

Case study · Amigos

Building Amigos: a restaurant site in Juba

A restaurant site for Amigos in Juba, built around its menu, events, and photography.

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

01The food was doing the talking

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. 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 comfortable with Sass and to write my own scroll animations instead of reaching for a library.

02Learning Sass by removing repetition

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

@mixin flexSection($height, $direction, $content, $items, $min: true) {
  @if $min {
    min-height: $height;
  } @else {
    height: $height;
  }

  display: flex;
  flex-direction: $direction;
  justify-content: $content;
  align-items: $items;
}

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 paid off. I pass its six image refs to useIntersectionObserver with a slide-up class, then stagger each one from its index. The dishes cascade into the grid as you reach them instead of arriving together.

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>
  );
})}

04Five clips instead of one four-minute video

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 picks one of those clips at random on each load. A return visit may open on a different clip, which gives the small set of footage more life.

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>
  );
};

05Shipping the smaller version

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 site is a client-rendered React app, so I used react-helmet-async to set the title, description, and social-share tags for each page. That did not turn the app into a server-rendered site, but it gave each route its own document metadata instead of one generic head.

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.

06The restaurant is gone, the site is not

Amigos closed. The site is still online, a small archive of a place a lot of people in Juba were fond of. Nobody briefs you for that, and it is probably the part I am most glad about.

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.