⌜⌞⌝⌟
∷∷∷∷

origin

https://cocktail-recipes.4hmartin.com/

section

Home

∷∷∷∷

category

practice

development type

frontend

Heber Martin Rodriguez - Project gif
⌜⌞⌝⌟
⌜⌞⌝⌟

◈overview

A full-featured React application for discovering, generating, and saving cocktail recipes, built as a practice project to demonstrate modern frontend architecture patterns.

⌜⌞⌝⌟

◈key_features

for_end_users

    01.
  • Search & browse

    Filter cocktails by category and ingredient/name using TheCocktailDB public API.

  • 02.
  • Recipe detail modal

    View ingredients, measures, and instructions for a selected drink.

  • 03.
  • Favorites

    Add/remove drinks from a favorites list, persisted to localStorage.

  • 04.
  • AI recipe generation

    Describe a drink in natural language and get a streamed, markdown-formatted recipe from an LLM via OpenRouter.

  • 05.
  • Toast notifications

    Lightweight feedback for validation errors and favorite actions.

for_admin

    01.
  • Project Structure
  • 1
    2src/
    3├── components/          # Reusable UI components
    4│   ├── DrinkCard.tsx
    5│   ├── Header.tsx        # Nav + home search form
    6│   ├── Modal.tsx          # Recipe detail modal (Headless UI Dialog)
    7│   └── Notification.tsx   # Toast notification
    8├── layouts/
    9│   └── Layout.tsx         # App shell: Header, <Outlet/>, Modal, Notification
    10├── views/                # Route-level pages
    11│   ├── Index.tsx           # Search results grid
    12│   ├── Favorites.tsx        # Saved favorites grid
    13│   └── GenerateAI.tsx        # AI recipe generator
    14├── stores/               # Zustand slices, combined in useAppStore.ts
    15│   ├── useAppStore.ts       # Combines all slices with devtools middleware
    16│   ├── recipeSlice.ts        # Categories, search results, selected recipe, modal state
    17│   ├── favoritesSlice.ts      # Favorites list + localStorage persistence
    18│   ├── notificationSlice.ts    # Toast state
    19│   └── aiSlice.ts               # AI generation state (streaming)
    20├── services/             # External API calls
    21│   ├── RecipeService.ts     # TheCocktailDB requests + Zod parsing
    22│   └── AIService.ts          # OpenRouter streamText call
    23├── lib/
    24│   ├── axios.ts             # Axios instance (TheCocktailDB base URL)
    25│   └── ai.ts                 # OpenRouter provider instance
    26├── utils/
    27│   └── recipes-schema.ts    # Zod schemas for all API responses
    28├── types/
    29│   └── index.ts              # Types inferred from Zod schemas
    30├── router.tsx             # BrowserRouter + lazy-loaded routes
    31├── main.tsx                # App entry point
    32└── index.css               # Tailwind import + custom keyframes
    02.
  • State Management

    The app uses Zustand's slice pattern: each domain of state lives in its own StateCreator, and all slices are combined into a single store in src/stores/useAppStore.ts, wrapped with the devtools middleware for Redux DevTools support.

    ◇ recipeSlice - categories, search results, selected recipe, modal visibility.

    ◇ favoritesSlice - favorites array; reads/writes localStorage under the favorites key; depends on notificationSlice for toast feedback (cross-slice access via get/set/api functions).

    ◇ notificationSlice - transient toast state with an auto-hide timeout.

    ◇ aiSlice - holds the streamed AI response string and an isGenerating flag.

  • 1// src/stores/useAppStore.ts
    2import {create} from "zustand";
    3import {devtools} from "zustand/middleware";
    4import {type RecipesSliceTypes, createRecipeSlice} from "./recipeSlice.ts";
    5import {type FavoritesSliceType, createFavoritesSlice} from "./favoritesSlice.ts";
    6import {type NotificationSliceType, createNotificationSlice} from "./notificationSlice.ts";
    7import {type AISlice, createAISlice} from "./aiSlice.ts";
    8
    9/**
    10 * @description Multiple stores using Slice Pattern.
    11 * '...a' contains a copy of the default functions to manipulate the state 'set(), get(), api()'.
    12 */
    13export const useAppStore = create<RecipesSliceTypes & FavoritesSliceType & NotificationSliceType & AISlice>()(
    14    devtools((...a) => ({
    15        ...createRecipeSlice(...a),
    16        ...createFavoritesSlice(...a),
    17        ...createNotificationSlice(...a),
    18        ...createAISlice(...a),
    19    }))
    20);
    03.
  • State Management - recipeSlice.ts

    Categories, search results, selected recipe, modal visibility.

  • 1// src/stores/recipeSlice.ts
    2import type { StateCreator } from "zustand"
    3import {getCategories, getRecipeById, getRecipes} from "../services/RecipeService.ts";
    4import type {Categories, Drink, Drinks, SearchFilters, Recipe} from "../types";
    5
    6"export type RecipesSliceTypes = {
    7    categories: Categories
    8    drinks: Drinks
    9    selectedRecipe: Recipe
    10    modal: boolean
    11
    12    fetchCategories: () => Promise<void>
    13    searchRecipes: (searchFilters: SearchFilters) => Promise<boolean>
    14    selectRecipe: (id: Drink['idDrink']) => Promise<void>
    15    closeModal: () => void
    16}
    17
    18export const createRecipeSlice: StateCreator<RecipesSliceTypes> = (set) => ({
    19    categories: {
    20        drinks: []
    21    },
    22    drinks: {
    23      drinks: []
    24    },
    25    selectedRecipe: {} as Recipe,
    26    modal: false,
    27    fetchCategories: async () => {
    28        const categories = await getCategories();
    29        set({
    30            categories: categories,
    31        })
    32    },
    33    searchRecipes: async (searchFilters) => {
    34        let success = false;
    35        const drinks = await getRecipes(searchFilters);
    36        set({
    37            drinks: drinks
    38        })
    39        if(drinks !== undefined) success = true;
    40        return success
    41    },
    42    selectRecipe: async (id) => {
    43        const selectedRecipe = await getRecipeById(id);
    44        set({
    45            selectedRecipe: selectedRecipe,
    46            modal: true
    47        })
    48    },
    49    closeModal: () => set({
    50        modal: false,
    51        selectedRecipe: {} as Recipe,
    52    })
    53})
    04.
  • Data Fetching & Validation

    All responses from TheCocktailDB are parsed through Zod schemas (src/utils/recipes-schema.ts) via safeParse, and the corresponding TypeScript types are inferred from those schemas (src/types/index.ts) rather than hand-written — schema and type stay in sync by construction.

  • 1// src/utils/recipes-schema.ts
    2import {z} from 'zod'
    3
    4export const CategoriesAPIResponseSchema = z.object({
    5    drinks: z.array(
    6        z.object({
    7            strCategory: z.string()
    8        })
    9    )
    10})
    11
    12export const SearchFiltersSchema = z.object({
    13    ingredient: z.string(),
    14    category: z.string()
    15})
    16
    17export const DrinkAPIResponse = z.object({
    18    idDrink: z.string(),
    19    strDrink: z.string(),
    20    strDrinkThumb: z.string(),
    21})
    22export const DrinksApiResponseSchema = z.object({
    23    drinks: z.array(DrinkAPIResponse),
    24})
    25
    26...
    05.
  • AI Recipe Generation

    src/views/GenerateAI.tsx submits a free-text prompt (e.g. 'A drink made with vodka and mango') to generateRecipe in aiSlice, which calls AIService.generateRecipe:

    The response is consumed as an async text stream and appended to aiSlice.recipe chunk by chunk, then rendered with react-markdown + remark-gfm (GitHub-flavored markdown: tables, strikethrough, task lists, etc.).

    Model note: Uses a free-tier model from OpenRouter (minimax/minimax-m2.7:free).

  • 1// src/services/AIService.ts
    2import { streamText } from "ai";
    3import { openRouter } from "../lib/ai";
    4
    5// List of available free models: https://openrouter.ai/models?variant=free&output_modalities=text&order=latency-low-to-high
    6// AI SDK VERCEL - System Prompts: https://ai-sdk.dev/docs/foundations/prompts#system-prompts
    7// AI SDK VERCEL - Temperature settings: https://ai-sdk.dev/docs/ai-sdk-core/prompt-engineering#temperature-settings
    8
    9export default {
    10    async generateRecipe(prompt: string){
    11        const result = streamText({
    12            model: openRouter('minimax/minimax-m2.7:free'),
    13            prompt,
    14            instructions: 'You are a professional bartender with over 50 years experience...',
    15            temperature: 0
    16        })
    17
    18        return result.textStream
    19    }
    20}
    06.
  • Data model - Types
  • 1// src/types/index.ts
    2import {
    3    CategoriesAPIResponseSchema,
    4    SearchFiltersSchema,
    5    DrinksApiResponseSchema,
    6    DrinkAPIResponse,
    7    RecipeAPIResponseSchema
    8} from "../utils/recipes-schema.ts";
    9import {z} from "zod";
    10
    11export type Categories = z.infer<typeof CategoriesAPIResponseSchema>;
    12export type SearchFilters = z.infer<typeof SearchFiltersSchema>;
    13export type Drinks = z.infer<typeof DrinksApiResponseSchema>;
    14export type Drink = z.infer<typeof DrinkAPIResponse>;
    15export type Recipe = z.infer<typeof RecipeAPIResponseSchema>;
⌜⌞⌝⌟

◈architecture_diagram

Project architecture diagram - 4hmartin
⌜⌞⌝⌟
⌜⌞⌝⌟

◈screenshots_&_demos

User interface main view desktop screenshot - 4hmartin
⌜⌞⌝⌟
User interface main view with search results - 4hmartin
⌜⌞⌝⌟
Modal open with selected recipe info - 4hmartin
⌜⌞⌝⌟
User interface - favorites view - 4hmartin
⌜⌞⌝⌟
Generate recipe using AI view - 4hmartin
⌜⌞⌝⌟
Generate recipe using AI example - 4hmartin
⌜⌞⌝⌟
Generate recipe using AI example - 4hmartin
⌜⌞⌝⌟
Generate recipe using AI example - 4hmartin
⌜⌞⌝⌟
Localstorage data example - 4hmartin
⌜⌞⌝⌟
⌜⌞⌝⌟

◈tech_stack

FOLLOW_THE_WHITE_RABBIT