(FEAT): Engagement for recipes is translated.

This commit is contained in:
Hayden Hargreaves 2025-11-19 13:44:41 -07:00
parent 2f5dd0dbc4
commit c68cb72ffb
14 changed files with 360 additions and 48 deletions

View File

@ -14,12 +14,17 @@ type AuthenticatedFunc func(ctx *gin.Context, user *domain.User)
// the function will return an error with a 401 status.
//
// BUG: This is probably not very effecient, since we hit the DB on every single protected request.
// If this ends up being a bottle neck we could simply hit the context for the userId, since
// that is usually all we need...Or maybe have two methods, for those that need the whole user
// and those that just need the ID.
//
// If this ends up being a bottle neck we could simply hit the context for the userId, since
// that is usually all we need...Or maybe have two methods, for those that need the whole user
// and those that just need the ID.
func (s *Server) withAuthenticatedUser(ctx *gin.Context, handler AuthenticatedFunc) {
user := s.deps.UserService.GetAuthenicatedUser(ctx)
if user == nil {
// User is stale, ensure they are logged out so they can be prompted to log back in
s.SetCookie(ctx, "jwt_token", "", -1)
// s.SetCookie(ctx, "search-filters", "", -1) // TODO: Might need this again
ctx.JSON(http.StatusUnauthorized, gin.H{
"status": http.StatusUnauthorized,
"message": "[UNAUTHORIZED] Could not fetch authenticated user.",
@ -29,17 +34,16 @@ func (s *Server) withAuthenticatedUser(ctx *gin.Context, handler AuthenticatedFu
handler(ctx, user)
}
// getUserId retrieves the userId from the context and returns a pointer to it. A nil
// pointer can be returned and will if the userId does not exist.
func getUserId(ctx *gin.Context) *int {
userIdAny, exists := ctx.Get("userId")
if !exists {
return nil
}
userIdInt, ok := userIdAny.(int)
if !ok {
return nil
}
return &userIdInt
userIdAny, exists := ctx.Get("userId")
if !exists {
return nil
}
userIdInt, ok := userIdAny.(int)
if !ok {
return nil
}
return &userIdInt
}

View File

@ -0,0 +1,143 @@
package server
import (
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
domain "github.com/haydenhargreaves/Potion/internal/domain/user"
)
func (s *Server) EngagementViewRecipeHandlerV2(ctx *gin.Context) {
recipeId, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to parse ID parameter. %s", err.Error()),
})
return
}
userId := getUserId(ctx)
if userId == nil {
if engagement, err := s.deps.EngagementService.ViewRecipe(recipeId); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to create engagement. %s", err.Error()),
})
} else {
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully created engagement.",
"engagement": engagement,
})
}
return
}
if engagement, err := s.deps.EngagementService.UserViewRecipe(*userId, recipeId); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to create engagement. %s", err.Error()),
})
} else {
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully created engagement.",
"engagement": engagement,
})
}
}
func (s *Server) EngagementShareRecipeHandlerV2(ctx *gin.Context) {
recipeId, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to parse ID parameter. %s", err.Error()),
})
return
}
userId := getUserId(ctx)
if userId == nil {
if engagement, err := s.deps.EngagementService.ShareRecipe(recipeId); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to create engagement. %s", err.Error()),
})
} else {
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully created engagement.",
"engagement": engagement,
})
}
return
}
if engagement, err := s.deps.EngagementService.UserShareRecipe(*userId, recipeId); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to create engagement. %s", err.Error()),
})
} else {
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully created engagement.",
"engagement": engagement,
})
}
}
func (s *Server) EngagementFavoriteRecipeHandlerV2(ctx *gin.Context) {
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domain.User) {
recipeId, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to parse ID parameter. %s", err.Error()),
})
return
}
if engagement, err := s.deps.EngagementService.UserFavoriteRecipe(user.Id, recipeId); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to create engagement. %s", err.Error()),
})
} else {
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully created engagement.",
"engagement": engagement,
})
}
})
}
func (s *Server) EngagementMakeRecipeHandlerV2(ctx *gin.Context) {
s.withAuthenticatedUser(ctx, func(ctx *gin.Context, user *domain.User) {
recipeId, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to parse ID parameter. %s", err.Error()),
})
return
}
if engagement, err := s.deps.EngagementService.UserMakeRecipe(user.Id, recipeId); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[ERROR] Failed to create engagement. %s", err.Error()),
})
} else {
ctx.JSON(http.StatusOK, gin.H{
"status": http.StatusOK,
"message": "[OK] Successfully created engagement.",
"engagement": engagement,
})
}
})
}

View File

@ -220,5 +220,10 @@ func (s *Server) Setup() *Server {
ctx.JSON(http.StatusOK, gin.H{"msg": "YAY"})
})
router_api_v2.POST("/engagement/view/:id", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementViewRecipeHandlerV2)
router_api_v2.POST("/engagement/share/:id", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementShareRecipeHandlerV2)
router_api_v2.POST("/engagement/favorite/:id", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementFavoriteRecipeHandlerV2)
router_api_v2.POST("/engagement/make/:id", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementMakeRecipeHandlerV2)
return s
}

View File

@ -24,7 +24,7 @@ func (s *Server) GetAuthenicatedUserRecipesV2(ctx *gin.Context) {
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[FAILED] Could not fetch authenticated users's recipes. %s", err.Error()),
"message": fmt.Sprintf("[ERROR] Could not fetch authenticated users's recipes. %s", err.Error()),
})
return
}
@ -43,7 +43,7 @@ func (s *Server) GetAuthenicatedUserFavoritesV2(ctx *gin.Context) {
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[FAILED] Could not fetch authenticated users's favorites. %s", err.Error()),
"message": fmt.Sprintf("[ERROR] Could not fetch authenticated users's favorites. %s", err.Error()),
})
return
}
@ -62,7 +62,7 @@ func (s *Server) GetAuthenicatedUserEngagementV2(ctx *gin.Context) {
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[FAILED] Failed to get authenticated user engagement. %s", err.Error()),
"message": fmt.Sprintf("[ERROR] Failed to get authenticated user engagement. %s", err.Error()),
})
return
}
@ -81,7 +81,7 @@ func (s *Server) GetAuthenicatedUserMadeRecipesV2(ctx *gin.Context) {
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[FAILED] Failed to get authenticated user's made recipes. %s", err.Error()),
"message": fmt.Sprintf("[ERROR] Failed to get authenticated user's made recipes. %s", err.Error()),
})
return
}
@ -100,7 +100,7 @@ func (s *Server) GetAuthenicatedUserViewedRecipesV2(ctx *gin.Context) {
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": fmt.Sprintf("[FAILED] Failed to get authenticated user's viewed recipes. %s", err.Error()),
"message": fmt.Sprintf("[ERROR] Failed to get authenticated user's viewed recipes. %s", err.Error()),
})
return
}

View File

@ -6,7 +6,7 @@ export default function Spinner({ content }: SpinnerProps) {
return (
<>
<div className="w-8 h-8 border-4 border-gray-200 border-t-blue-500 rounded-full animate-spin" />
<h2 className="text-xl text-gray-700"> { content }</h2>
<h2 className="text-xl text-gray-700"> {content}</h2>
</>
);
}

View File

@ -1,4 +1,8 @@
import { useEffect, useState } from "react";
import { use, useEffect, useState } from "react";
import { AuthContext } from "../../context/AuthContext";
import { useNavigate } from "react-router-dom";
import { EngagementFavoriteRecipe } from "../../services/EngagementService";
import { isApiError } from "../../types/api/error";
interface FavoriteButtonProps {
@ -7,11 +11,29 @@ interface FavoriteButtonProps {
}
export default function FavoriteButton({ favorite, id }: FavoriteButtonProps) {
// CONTEXT
const { isLoggedIn } = use(AuthContext);
const navigate = useNavigate();
const [_favorite, setFavorite] = useState<boolean>();
const clickHandler = () => {
// TODO: Implement action here!
const clickHandler = async () => {
// This button cannot be used if not logged in
if (!isLoggedIn) {
await navigate("/v2/web/login");
return;
}
if (!id) return;
// Toggle button first, to feel fast
setFavorite(!_favorite);
const result = await EngagementFavoriteRecipe(id);
if (isApiError(result)) {
console.error(result.message);
}
}
useEffect(() => {
@ -23,7 +45,7 @@ export default function FavoriteButton({ favorite, id }: FavoriteButtonProps) {
return _favorite ? (
<button
className="flex items-center justify-center gap-x-1 rounded-lg border border-blue-300 bg-blue-50 text-gray-800 px-6 py-3 flex-grow hover:bg-blue-100 hover:border-blue-500 duration-300 cursor-pointer"
onClick={clickHandler}
onClick={() => void clickHandler()}
>
<svg className="h-6 text-red-500" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
@ -37,7 +59,7 @@ export default function FavoriteButton({ favorite, id }: FavoriteButtonProps) {
) : (
<button
className="flex items-center justify-center gap-x-1 rounded-lg border border-gray-300 text-gray-800 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300 cursor-pointer"
onClick={clickHandler}
onClick={() => void clickHandler()}
>
<svg className="h-6" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path

View File

@ -1,25 +1,42 @@
import { useState } from "react";
import { use, useState } from "react";
import { AuthContext } from "../../context/AuthContext";
import { useNavigate } from "react-router-dom";
import { EngagementMakeRecipe } from "../../services/EngagementService";
import { isApiError } from "../../types/api/error";
interface MadeButtonProps {
id: number;
}
export default function MadeButton({ id }: MadeButtonProps) {
// CONTEXT
const { isLoggedIn } = use(AuthContext);
const navigate = useNavigate();
const [clicked, setClicked] = useState<boolean>(false);
const clickHandler = () => {
if (clicked) return;
const clickHandler = async () => {
// This button cannot be used if not logged in
if (!isLoggedIn) {
await navigate("/v2/web/login");
return;
}
// TODO: Implement actions
if (!id || clicked) return;
// Toggle button first, to feel fast
setClicked(true);
const result = await EngagementMakeRecipe(id);
if (isApiError(result)) {
console.error(result.message);
}
}
return (
<button
className={`flex items-center justify-center gap-x-1 rounded-lg border ${clicked ? "border-blue-500 text-blue-500" : "border-gray-300 text-gray-800"} px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300 cursor-pointer`}
onClick={clickHandler}
onClick={() => void clickHandler()}
>
<svg
className="h-6"

View File

@ -1,17 +1,28 @@
import { useState } from "react";
import { EngagementShareRecipe } from "../../services/EngagementService";
import { isApiError } from "../../types/api/error";
interface ShareButtonProps {
id: number;
}
// TODO: Abstract this somehow, this needs to be loaded from the env
const DOMAIN = "http://localhost:5173/";
export default function ShareButton({ id }: ShareButtonProps) {
const [clicked, setClicked] = useState<boolean>(false);
const clickHandler = () => {
const clickHandler = async () => {
if (clicked) return;
// TODO: Implement action here
// Copy first, so it feels fast
await navigator.clipboard.writeText(`${DOMAIN}/v2/web/recipe/${id}`)
const result = await EngagementShareRecipe(id);
if (isApiError(result)) {
console.error(result.message);
}
setClicked(true);
};
@ -30,7 +41,7 @@ export default function ShareButton({ id }: ShareButtonProps) {
) : (
<button
className="flex items-center justify-center gap-x-1 rounded-lg border border-gray-300 text-gray-800 px-6 py-3 flex-grow hover:bg-gray-50 hover:border-blue-300 duration-300 cursor-pointer"
onClick={clickHandler}
onClick={() => void clickHandler()}
>
<svg className="h-7" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path

View File

@ -1,16 +1,29 @@
import type { Recipe } from "../../types/recipe";
import RecipePlaceholder from "../../assets/images/recipe_placeholder.png"
import LikeButton from "../buttons/LikeButton";
import { useNavigate } from "react-router-dom";
import { EngagementViewRecipe } from "../../services/EngagementService";
import { isApiError } from "../../types/api/error";
interface RecipeCardLargeProps {
recipe: Recipe | null;
}
export default function RecipeCardLarge({ recipe }: RecipeCardLargeProps) {
const navigate = useNavigate();
// HANDLERS
const makeButtonHandler = () => console.log("makeButtonHandler()");
const makeButtonHandler = async () => {
if (!recipe) return;
// Navigate first, so it feels faster
await navigate(`/v2/web/recipe/${recipe.Id}`);
const result = await EngagementViewRecipe(recipe.Id);
if (isApiError(result)) {
console.error(result.message);
}
}
if (recipe == null) {
return <h2 className="text-2xl md:text-3xl text-gray-400">Coming soon!</h2>
@ -36,7 +49,7 @@ export default function RecipeCardLarge({ recipe }: RecipeCardLargeProps) {
{recipe.Favorite && <LikeButton />}
</div>
<button
onClick={makeButtonHandler}
onClick={() => void makeButtonHandler()}
className="w-full rounded-lg py-2 bg-gradient-to-r from-blue-400 to-blue-600 text-white mt-2 hover:ring-blue-700 hover:shadow shadow-blue-300 duration-200 cursor-pointer"
>
Make Now!

View File

@ -1,15 +1,27 @@
import type { Recipe } from "../../types/recipe";
import RecipePlaceholder from "../../assets/images/recipe_placeholder.png"
import LikeButton from "../buttons/LikeButton";
import { EngagementViewRecipe } from "../../services/EngagementService";
import { isApiError } from "../../types/api/error";
import { useNavigate } from "react-router-dom";
interface RecipeCardSmallProps {
recipe: Recipe;
}
export default function RecipeCardSmall({ recipe }: RecipeCardSmallProps) {
const navigate = useNavigate();
// HANDLERS
const makeButtonHandler = () => console.log("makeButtonHandler()");
const makeButtonHandler = async () => {
// Navigate first, so it feels faster
await navigate(`/v2/web/recipe/${recipe.Id}`);
const result = await EngagementViewRecipe(recipe.Id);
if (isApiError(result)) {
console.error(result.message);
}
}
return (
<div className="flex flex-col items-center justify-between rounded-lg border-gray-300 border shadow-md p-4 flex-shrink-0">
@ -28,7 +40,7 @@ export default function RecipeCardSmall({ recipe }: RecipeCardSmallProps) {
{recipe.Favorite && <LikeButton />}
</div>
<button
onClick={makeButtonHandler}
onClick={() => void makeButtonHandler()}
className="w-full rounded-lg py-2 bg-gradient-to-r from-blue-400 to-blue-600 text-white mt-2 hover:ring-blue-700 hover:shadow shadow-blue-300 duration-200 cursor-pointer"
>
Make Now!

View File

@ -3,22 +3,22 @@ interface InstructionListProps {
}
export default function InstructionList({ instructions }: InstructionListProps) {
return (
<>
<div className="px-4 py-8 md:px-8">
<h2 className="text-2xl text-gray-800 font-semibold mb-2">Instructions</h2>
<hr className="text-gray-300"/>
<ul className="text-lg my-4 text-gray-700">
<>
<div className="px-4 py-8 md:px-8">
<h2 className="text-2xl text-gray-800 font-semibold mb-2">Instructions</h2>
<hr className="text-gray-300" />
<ul className="text-lg my-4 text-gray-700">
{instructions?.map((instruction, i) => (
<li key={instruction} className="p-4 flex items-start gap-x-4 odd:bg-[#f8f8f8]">
<div className="size-8 md:size-10 bg-blue-50 rounded-full flex items-center justify-center flex-shrink-0">
<h3 className="text-base md:text-xl text-blue-600 font-semibold">{ i + 1}</h3>
<h3 className="text-base md:text-xl text-blue-600 font-semibold">{i + 1}</h3>
</div>
<p className="text-base">{ instruction }</p>
<p className="text-base">{instruction}</p>
</li>
))}
</ul>
</div>
</ul>
</div>
</>
</>
);
}

View File

@ -63,7 +63,6 @@ export default function RecipePage() {
<TagList tags={recipe.Tags} created={recipe.Created} modified={recipe.Modified} />
</>
) : (
/* TODO: Implement a loading page! */
<div className="min-h-[90vh] flex items-center justify-center gap-x-4">
<Spinner content={"Recipe is loading..."} />
</div>

View File

@ -0,0 +1,60 @@
import axios from "axios";
import type { ApiError } from "../types/api/error";
import type { Engagement } from "../types/engagement";
import type { EngagementFavoriteRecipeResponse, EngagementMakeRecipeResponse, EngagementShareRecipeResponse, EngagementViewRecipeResponse } from "../types/api/engagement";
export async function EngagementViewRecipe(recipeId: number): Promise<Engagement | ApiError> {
const response = await axios.post<EngagementViewRecipeResponse>(`http://localhost:3000/v2/api/engagement/view/${recipeId}`);
if (response.status !== 200 || response.data.engagement === undefined) {
const err: ApiError = {
status: response.data.status,
message: response.data.message
};
return err;
}
return response.data.engagement;
}
export async function EngagementShareRecipe(recipeId: number): Promise<Engagement | ApiError> {
const response = await axios.post<EngagementShareRecipeResponse>(`http://localhost:3000/v2/api/engagement/share/${recipeId}`);
if (response.status !== 200 || response.data.engagement === undefined) {
const err: ApiError = {
status: response.data.status,
message: response.data.message
};
return err;
}
return response.data.engagement;
}
export async function EngagementFavoriteRecipe(recipeId: number): Promise<Engagement | ApiError> {
const response = await axios.post<EngagementFavoriteRecipeResponse>(`http://localhost:3000/v2/api/engagement/favorite/${recipeId}`);
if (response.status !== 200 || response.data.engagement === undefined) {
const err: ApiError = {
status: response.data.status,
message: response.data.message
};
return err;
}
return response.data.engagement;
}
export async function EngagementMakeRecipe(recipeId: number): Promise<Engagement | ApiError> {
const response = await axios.post<EngagementMakeRecipeResponse>(`http://localhost:3000/v2/api/engagement/make/${recipeId}`);
if (response.status !== 200 || response.data.engagement === undefined) {
const err: ApiError = {
status: response.data.status,
message: response.data.message
};
return err;
}
return response.data.engagement;
}

View File

@ -0,0 +1,26 @@
import type { Engagement } from "../engagement";
export interface EngagementViewRecipeResponse {
status: number;
message: string;
engagement?: Engagement;
}
export interface EngagementShareRecipeResponse {
status: number;
message: string;
engagement?: Engagement;
}
export interface EngagementFavoriteRecipeResponse {
status: number;
message: string;
engagement?: Engagement;
}
export interface EngagementMakeRecipeResponse {
status: number;
message: string;
engagement?: Engagement;
}