Omnifert - Figmafection!

omnifert-title

Link to live project!

For quite some time, I have been practicing my skills and sharpening them by recreating websites through various sources (such as Frontend Mentor). Although these have been very beneficial to me, there is a small problem: nearly all of them have been single-paged projects.

Don't get me wrong, I still learned quite a bunch from recreating these pages (and even trying out integrating new technologies to them), but my developer-self within me wanted more. To feed this hunger, I wanted to expand and try creating a website that had more pages and functionality. Hear about how I planned this project and tackled some interesting functionality on this development journey (namely Nanostores and Content Collections).

Figma Community

I am by no means a design master when it comes to developing something from scratch, but to keep my skills sharp, I often utilize various sources as reference as well as gauge what kind of designs are popular for the current times β€” Figma Community is one of them.

omnifert figma preview

After scrolling through some community-made templates, I found one that caught my eye β€” Omnifert - Website Template by Maurri KonΓ©. This e-commerce template has multiple pages, a built-in design system, and is well-organized within the Figma file itself.

Looking at how well done this template was, I thought to myself: "Why not take up the challenge myself and recreate this?" πŸ€”

Planning Phase

Before diving right into my code editor, I needed to plan out my game plan for recreating this Figma design. My original intent for this project is to further hone my frontend development skills, so there is no need to implement backend functionality. However, I still wanted to implement some kind of cart functionality to at least show the fundamentals of an e-commerce site.

There were a couple of things I needed to think about:

  • Develop a common layout that is shared amongst all pages
  • Dynamic pages for each product item listing
  • Dynamic pages for each article post
  • Search functionality for article posts
  • Sample functionality of adding product items (and their information/costs) to the cart

omni-brainstorm

Omnifert Layout

Development Stack

For this project, I chose the following:

  • Astro
  • React (Typescript)
  • Tailwind CSS
  • Zod
  • Nanostores
  • shadcnui
  • Astro Content Collections

The reasons for choosing this stack is because I wanted to further my skills beyond the basics for each resource listed here. This project is originally content-heavy and thus, Astro is deemed as the best choice for this as Astro's main purpose is a web framework for content-driven websites, such as this one. Utilizing Astro's unique Content Collection allows for easy organization of documents (in this case, item listings and article blog posts), type validation via Zod, and easy fetching.

Nanostores are used as the main way to create a global shared state between all pages. It is one of the recommended way to share state between Astro and framework-agnostic components for Astro and because it fit my use case, I wanted to try it out for this project. See here for information on Nanostores.

Various UI components are created with the usage of shadcnui, customized to fit well with the given design system. Customization done with the help of Tailwind CSS.

Cart Functionality

Nanostores is what is used to create the dummy cart functionality for this project. It is a light-weight state manager used for framework-agnostic development where multiple different frameworks can be combined without error. From the time that I used it, I really love it due to its flexibility and easy to implement within Astro.

State containers for Nanostores are defined as stores, and each store defines the type of data (or state) to be stored. For this project, I went ahead and utilized Persistent Stores, a type of smart store which keeps data within the browser's localStorage.

Why not keep data utilizing native localStorage functons instead?

It's simple really, I wanted to learn something new and beyond what I already know and since it was featured on Astro's documentation, I figured to give it a shot to see how powerful it is (it meshes well with Typescript and even has good support, so that's also a plus).

Cart Item

In order to start utilizing Nanostores, I needed to define the type for my CartItem (or product item):

Cart Item

export type CartItem = {
  id: string;         // unique id 
  title: string;      // name of product
  image: {
    src: string;
    width: number;
    height: number;
    format: 'png' | 'jpg' | 'jpeg' | 'tiff' | 'webp' | 'gif' | 'svg' | 'avif';
  };                  // image 
  quantity: number;   // product quantity
  price: number;      // product price
};

The Cart Itself

Next, I needed to define the cart itself, as a persistent cart. Luckily, it is easy to define from reading the documentation for it:

import { persistentAtom } from '@nanostores/persistent';

...

// Cart Store
export const cartItems = persistentAtom('cart', [], {
  encode: JSON.stringify,
  decode: JSON.parse,
});

This store will keep all of its data as a cart key within localStorage. Default value will be an empty array and fetched data is encoded/decoded properly.

Add Items to Cart

Lastly, there needs to be a function that allows adding the items to the cart. In order to do this, I utilized computed stores, which are unique stores that store values and are updated each time their dependency store is updated. In this case, whenever the original cart is changed, this computed store unique cart values such as total cartQuantity and total cartPrice.

Item Display for Cart

type ItemDisplay = Pick<
  CartItem,
  'id' | 'image' | 'quantity' | 'title' | 'price'
>;

Add To Cart Function

export function addToCart({ id, title, image, quantity, price }: ItemDisplay) {
  const existingItem = cartItems.get().filter((items) => items.id === id);

  // If item exist (array is not 0), update its quantity, else add new cart item
  if (existingItem.length != 0) {
    const filterCart = cartItems.get().filter((items) => items.id !== id);
    const updatedQuantity = existingItem[0].quantity + quantity;
    cartItems.set([
      ...filterCart,
      { id, title, image, quantity: updatedQuantity, price },
    ]);
  } else if (quantity > 0) {
    cartItems.set([...cartItems.get(), { id, title, image, quantity, price }]);
  }
}

export function getStore() {
  return cartItems.get();
}

// computed store to show total cart quantity
export const cartQuantity = computed(cartItems, (items) => {
  return items.reduce((total, item) => total + item.quantity, 0);
});

// computed store to show total cart quantity
export const cartPrice = computed(cartItems, (items) => {
  return items.reduce((total, item) => total + item.price * item.quantity, 0);
});

The function works as follows:

  1. Fetch all existing items using a JavaScript filter function
  2. If there are any items, update its quantity if the item being added is the same name as it, otherwise create a new cart item
  3. set() and get() are setter and getter functions unique to Nanostores. See the Atoms section on their documentation on how it works

Once the functionality has been created, it is simply a matter of exporting these functions to the respective component, giving the cart functional logic to work!

src/components/products/ProductForm.tsx

import { addToCart } from '@/nanostores/cartStorePersist';

...
     onSubmit={(e) => {
        e.preventDefault();

        if (
          id != undefined &&
          title != undefined &&
          image != undefined &&
          price != undefined
        ) {
          addToCart({ id, title, image, quantity, price });
        }
        setQuantity(0);
      }}
    >

product item add to cart

check out from cart

Handling Article posts and Product listings

Utilizing Astro's Content Collection feature, I created the following config.ts needed for Typescript to generate type-safety for my content.

Article Collection

const articleCollection = defineCollection({
  type: 'content',
  schema: ({ image }) =>
    z.object({
      title: z.string(),
      author: z.string(),
      date: z.string(),
      image: image(),
      imageAlt: z.string(),
      popular: z.boolean(),
    }),
});

Product Items Collection

const itemsCollection = defineCollection({
  type: 'data',
  schema: ({ image }) =>
    z.object({
      id: z.number(),
      title: z.string(),
      date: z.string(),
      image: image(),
      imageAlt: z.string(),
      imagePreviews: z.string().optional(),
      price: z.number(),
      oldPrice: z.string().optional(),
      desc: z.string().optional(),
      specification: z.array(z.string()),
      sale: z.boolean(),
      slug: z.string(),
    }),
});

Once each collection has been exported, it was simply a matter of creating the respective markdown file containing the details of the collection's schema and boom, simple type-safe content that is easily fetchable wherever needed within the project. See more about Content Collections and why it's amazing.

Conclusion

Working on this took me quite some time, and although I didn't really focus on much of the animations that the original working site has; of course, I focused strictly on the Figma file itself, so there will be differences compared to the live version of the site, I still learned quite a bit from this project and am proud of what I was able to accomplish at the end of the day. 😁

Useful Resources

  1. Astro Documentation
  2. Tailwind Documentation
  3. shadcn/ui
  4. Fontgeek - Gotham Rounded
  5. Zod Documentation
  6. React Hook Form Documentation
  7. Fontsource
  8. Nanostores
  9. State management with Astro and @nanostores - Petr Tcoi