mirror of
https://github.com/frontendnetwork/vegancheck.me
synced 2024-11-13 23:57:09 +00:00
feat: Initialize API v1 for Ingredients
This commit is contained in:
parent
7aa9a50207
commit
d15cac07eb
11 changed files with 199 additions and 128 deletions
|
@ -4,7 +4,7 @@
|
|||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"dev": "next dev --turbopack",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start -p 1030",
|
||||
"stage": "next start -p 1031",
|
||||
|
@ -25,7 +25,7 @@
|
|||
"dependencies": {
|
||||
"@ducanh2912/next-pwa": "^10.2.9",
|
||||
"@ericblade/quagga2": "^1.8.4",
|
||||
"@frontendnetwork/veganify": "^1.2.9",
|
||||
"@frontendnetwork/veganify": "^1.3.0",
|
||||
"@types/node": "22.8.1",
|
||||
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
|
||||
"jest-worker": "^29.7.0",
|
||||
|
|
|
@ -30,8 +30,8 @@ importers:
|
|||
specifier: ^1.8.4
|
||||
version: 1.8.4
|
||||
'@frontendnetwork/veganify':
|
||||
specifier: ^1.2.9
|
||||
version: 1.2.9
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.0
|
||||
'@types/node':
|
||||
specifier: 22.8.1
|
||||
version: 22.8.1
|
||||
|
@ -819,8 +819,8 @@ packages:
|
|||
'@formatjs/intl-localematcher@0.5.6':
|
||||
resolution: {integrity: sha512-roz1+Ba5e23AHX6KUAWmLEyTRZegM5YDuxuvkHCyK3RJddf/UXB2f+s7pOMm9ktfPGla0g+mQXOn5vsuYirnaA==}
|
||||
|
||||
'@frontendnetwork/veganify@1.2.9':
|
||||
resolution: {integrity: sha512-npn7Y51JW9c2IakO4X9BAByZoSm9NhJXDNE3W0+kJR7G7KBmOr1R+8xs7QB4wQ/cTeuS0SHY90QbWubnCgoawg==}
|
||||
'@frontendnetwork/veganify@1.3.0':
|
||||
resolution: {integrity: sha512-0vikV+EnScvrI7/Gi1cpJynomiSGz2t7HAWcR0tLUhhdlBmDimAvIxgWZYxPl2hEYjI0J5PSHNxltV/nePndAQ==}
|
||||
|
||||
'@humanfs/core@0.19.0':
|
||||
resolution: {integrity: sha512-2cbWIHbZVEweE853g8jymffCA+NCMiuqeECeBBLm8dg2oFdjuGJhgN4UAbI+6v0CKbbhvtXA4qV8YR5Ji86nmw==}
|
||||
|
@ -5068,7 +5068,7 @@ snapshots:
|
|||
dependencies:
|
||||
tslib: 2.6.2
|
||||
|
||||
'@frontendnetwork/veganify@1.2.9': {}
|
||||
'@frontendnetwork/veganify@1.3.0': {}
|
||||
|
||||
'@humanfs/core@0.19.0': {}
|
||||
|
||||
|
|
|
@ -14,14 +14,21 @@ export function IngredientsForm() {
|
|||
vegan: null,
|
||||
surelyVegan: [],
|
||||
notVegan: [],
|
||||
maybeVegan: [],
|
||||
maybeNotVegan: [],
|
||||
unknown: [],
|
||||
});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setResult({ vegan: null, surelyVegan: [], notVegan: [], maybeVegan: [] });
|
||||
setResult({
|
||||
vegan: null,
|
||||
surelyVegan: [],
|
||||
notVegan: [],
|
||||
maybeNotVegan: [],
|
||||
unknown: [],
|
||||
});
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
|
|
|
@ -1,23 +1,41 @@
|
|||
import React from "react";
|
||||
import { IconClassType } from "./models/Tooltip";
|
||||
import { TranslationFunction } from "./models/TranslateFunction";
|
||||
import { TooltipClient } from "./shared/Tooltip";
|
||||
|
||||
export function IngredientList({
|
||||
items,
|
||||
iconClass,
|
||||
}: {
|
||||
interface IngredientListProps {
|
||||
items: string[];
|
||||
iconClass: string;
|
||||
}) {
|
||||
iconClass: IconClassType;
|
||||
t: TranslationFunction;
|
||||
}
|
||||
|
||||
export function IngredientList({ items, iconClass, t }: IngredientListProps) {
|
||||
const tooltipMessages = {
|
||||
"maybe-vegan": t("maybe_vegan"),
|
||||
"unknown-vegan": t("unknown_vegan"),
|
||||
} as const;
|
||||
|
||||
const shouldShowTooltip =
|
||||
iconClass.includes("maybe-vegan") || iconClass.includes("unknown-vegan");
|
||||
const tooltipBaseClass = iconClass.split(
|
||||
" "
|
||||
)[0] as keyof typeof tooltipMessages;
|
||||
const tooltipMessage = shouldShowTooltip
|
||||
? tooltipMessages[tooltipBaseClass]
|
||||
: "";
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.map((item) => (
|
||||
<div className="Grid" key={item}>
|
||||
<div className="Grid-cell description">
|
||||
{item.charAt(0).toUpperCase() + item.slice(1)}
|
||||
<TooltipClient message={tooltipMessage as string} key={item}>
|
||||
<div className="Grid">
|
||||
<div className="Grid-cell description">
|
||||
{item.charAt(0).toUpperCase() + item.slice(1)}
|
||||
</div>
|
||||
<div className="Grid-cell icons">
|
||||
<span className={iconClass}></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="Grid-cell icons">
|
||||
<span className={iconClass}></span>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipClient>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import { IngredientList } from "./IngredientsList";
|
||||
import { IngredientResult } from "./models/IngredientResult";
|
||||
import { TranslationFunction } from "./models/TranslateFunction";
|
||||
import { SourceInfo } from "./SourceInfo";
|
||||
|
||||
export function ResultDisplay({
|
||||
result,
|
||||
t,
|
||||
}: {
|
||||
interface ResultDisplayProps {
|
||||
result: IngredientResult;
|
||||
t: (key: string, values?: Record<string, string>) => string;
|
||||
}) {
|
||||
t: TranslationFunction;
|
||||
}
|
||||
|
||||
export function ResultDisplay({ result, t }: ResultDisplayProps) {
|
||||
return (
|
||||
<div id="result">
|
||||
<div className="">
|
||||
|
@ -28,14 +28,22 @@ export function ResultDisplay({
|
|||
<IngredientList
|
||||
items={result.notVegan}
|
||||
iconClass="non-vegan icon-cancel"
|
||||
t={t}
|
||||
/>
|
||||
<IngredientList
|
||||
items={result.maybeVegan}
|
||||
iconClass="maybe-vegan icon-help"
|
||||
items={result.unknown}
|
||||
iconClass="unknown-vegan icon-help"
|
||||
t={t}
|
||||
/>
|
||||
<IngredientList
|
||||
items={result.maybeNotVegan}
|
||||
iconClass="maybe-vegan icon-attention-alt"
|
||||
t={t}
|
||||
/>
|
||||
<IngredientList
|
||||
items={result.surelyVegan}
|
||||
iconClass="vegan icon-ok"
|
||||
t={t}
|
||||
/>
|
||||
<SourceInfo t={t} />
|
||||
</div>
|
||||
|
|
|
@ -2,5 +2,6 @@ export interface IngredientResult {
|
|||
vegan: boolean | null;
|
||||
surelyVegan: string[];
|
||||
notVegan: string[];
|
||||
maybeVegan: string[];
|
||||
maybeNotVegan: string[];
|
||||
unknown: string[];
|
||||
}
|
||||
|
|
10
src/components/IngredientsCheck/models/Tooltip.ts
Normal file
10
src/components/IngredientsCheck/models/Tooltip.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
export type IconClassType =
|
||||
| "vegan icon-ok"
|
||||
| "non-vegan icon-cancel"
|
||||
| "unknown-vegan icon-help"
|
||||
| "maybe-vegan icon-attention-alt";
|
||||
|
||||
export interface TooltipMessages {
|
||||
"maybe-vegan": string;
|
||||
"unkown-vegan": string;
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
export type TranslationFunction = (
|
||||
key: string,
|
||||
values?: Record<string, string>
|
||||
) => string;
|
32
src/components/IngredientsCheck/shared/Tooltip.tsx
Normal file
32
src/components/IngredientsCheck/shared/Tooltip.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
interface TooltipProps {
|
||||
message: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function TooltipClient({ message, children }: TooltipProps) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
if (!message) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="tooltip-wrapper"
|
||||
onMouseEnter={() => setIsVisible(true)}
|
||||
onMouseLeave={() => setIsVisible(false)}
|
||||
>
|
||||
{children}
|
||||
{isVisible && (
|
||||
<div className="tooltip">
|
||||
{message}
|
||||
<div className="tooltip-arrow"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,136 +1,126 @@
|
|||
import Veganify from "@frontendnetwork/veganify";
|
||||
|
||||
import { checkIngredients } from "./actions";
|
||||
|
||||
jest.mock("@frontendnetwork/veganify", () => ({
|
||||
getProductByBarcode: jest.fn(),
|
||||
__esModule: true,
|
||||
default: {
|
||||
checkIngredientsListV1: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
async function testFetchProduct(barcode: string) {
|
||||
try {
|
||||
const data = await Veganify.getProductByBarcode(
|
||||
barcode,
|
||||
process.env.NEXT_PUBLIC_STAGING === "true"
|
||||
);
|
||||
if (data && "product" in data && "sources" in data) {
|
||||
return {
|
||||
product: data.product,
|
||||
sources: data.sources,
|
||||
status: data.status,
|
||||
};
|
||||
} else if (data && "status" in data) {
|
||||
return {
|
||||
status: data.status,
|
||||
};
|
||||
}
|
||||
throw new Error("Invalid response format");
|
||||
} catch (error) {
|
||||
console.error("Product fetch error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
describe("fetchProduct", () => {
|
||||
describe("checkIngredients", () => {
|
||||
// Reset all mocks before each test
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// Reset environment variables
|
||||
process.env.NEXT_PUBLIC_STAGING = undefined;
|
||||
});
|
||||
|
||||
it("returns product and sources when available", async () => {
|
||||
const mockResponse = {
|
||||
product: {
|
||||
name: "Test Product",
|
||||
it("should successfully check ingredients and return formatted data", async () => {
|
||||
// Mock successful API response
|
||||
const mockApiResponse = {
|
||||
data: {
|
||||
vegan: true,
|
||||
surely_vegan: ["apple", "banana"],
|
||||
not_vegan: [],
|
||||
maybe_not_vegan: [],
|
||||
unknown: ["artificial-flavor"],
|
||||
},
|
||||
sources: {
|
||||
openFoodFacts: true,
|
||||
},
|
||||
status: 200,
|
||||
};
|
||||
|
||||
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValueOnce(
|
||||
mockResponse
|
||||
(Veganify.checkIngredientsListV1 as jest.Mock).mockResolvedValue(
|
||||
mockApiResponse
|
||||
);
|
||||
|
||||
const result = await testFetchProduct("4000417025005");
|
||||
const result = await checkIngredients("apple, banana, artificial-flavor");
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(Veganify.getProductByBarcode).toHaveBeenCalledWith(
|
||||
"4000417025005",
|
||||
false
|
||||
// Check if the function returns the expected formatted data
|
||||
expect(result).toEqual({
|
||||
vegan: true,
|
||||
surelyVegan: ["apple", "banana"],
|
||||
notVegan: [],
|
||||
maybeNotVegan: [],
|
||||
unknown: ["artificial-flavor"],
|
||||
});
|
||||
|
||||
// Verify Veganify was called with correct parameters
|
||||
expect(Veganify.checkIngredientsListV1).toHaveBeenCalledWith(
|
||||
"apple, banana, artificial-flavor",
|
||||
process.env.NEXT_PUBLIC_STAGING === "true"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns only status when no product found", async () => {
|
||||
const mockResponse = {
|
||||
status: 404,
|
||||
};
|
||||
|
||||
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValueOnce(
|
||||
mockResponse
|
||||
it("should throw an error when ingredients string is empty", async () => {
|
||||
await expect(checkIngredients("")).rejects.toThrow(
|
||||
"Ingredients cannot be empty"
|
||||
);
|
||||
await expect(checkIngredients(" ")).rejects.toThrow(
|
||||
"Ingredients cannot be empty"
|
||||
);
|
||||
|
||||
const result = await testFetchProduct("4000417025005");
|
||||
|
||||
expect(result).toEqual({ status: 404 });
|
||||
// Verify Veganify was not called
|
||||
expect(Veganify.checkIngredientsListV1).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws error when API call fails", async () => {
|
||||
const mockError = new Error("API Error");
|
||||
(Veganify.getProductByBarcode as jest.Mock).mockRejectedValueOnce(
|
||||
mockError
|
||||
it("should throw an error when API call fails", async () => {
|
||||
// Mock API failure
|
||||
(Veganify.checkIngredientsListV1 as jest.Mock).mockRejectedValue(
|
||||
new Error("API Error")
|
||||
);
|
||||
|
||||
await expect(testFetchProduct("4000417025005")).rejects.toThrow(
|
||||
"API Error"
|
||||
await expect(checkIngredients("apple")).rejects.toThrow(
|
||||
"Failed to check ingredients"
|
||||
);
|
||||
|
||||
// Verify Veganify was called
|
||||
expect(Veganify.checkIngredientsListV1).toHaveBeenCalledWith(
|
||||
"apple",
|
||||
process.env.NEXT_PUBLIC_STAGING === "true"
|
||||
);
|
||||
});
|
||||
|
||||
it("uses staging flag correctly", async () => {
|
||||
const mockResponse = {
|
||||
product: {
|
||||
name: "Test Product",
|
||||
vegan: true,
|
||||
it("should handle non-vegan ingredients correctly", async () => {
|
||||
// Mock API response with non-vegan ingredients
|
||||
const mockApiResponse = {
|
||||
data: {
|
||||
vegan: false,
|
||||
surely_vegan: ["apple"],
|
||||
not_vegan: ["gelatin"],
|
||||
maybe_not_vegan: ["sugar"],
|
||||
unknown: [],
|
||||
},
|
||||
sources: {
|
||||
openFoodFacts: true,
|
||||
},
|
||||
status: 200,
|
||||
};
|
||||
|
||||
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValue(mockResponse);
|
||||
(Veganify.checkIngredientsListV1 as jest.Mock).mockResolvedValue(
|
||||
mockApiResponse
|
||||
);
|
||||
|
||||
const result = await checkIngredients("apple, gelatin, sugar");
|
||||
|
||||
expect(result).toEqual({
|
||||
vegan: false,
|
||||
surelyVegan: ["apple"],
|
||||
notVegan: ["gelatin"],
|
||||
maybeNotVegan: ["sugar"],
|
||||
unknown: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle staging environment flag correctly", async () => {
|
||||
const originalEnv = process.env.NEXT_PUBLIC_STAGING;
|
||||
|
||||
// Test with staging true
|
||||
process.env.NEXT_PUBLIC_STAGING = "true";
|
||||
await testFetchProduct("4000417025005");
|
||||
expect(Veganify.getProductByBarcode).toHaveBeenLastCalledWith(
|
||||
"4000417025005",
|
||||
true
|
||||
);
|
||||
await checkIngredients("apple");
|
||||
expect(Veganify.checkIngredientsListV1).toHaveBeenCalledWith("apple", true);
|
||||
|
||||
// Test with staging false
|
||||
process.env.NEXT_PUBLIC_STAGING = "false";
|
||||
await testFetchProduct("4000417025005");
|
||||
expect(Veganify.getProductByBarcode).toHaveBeenLastCalledWith(
|
||||
"4000417025005",
|
||||
await checkIngredients("apple");
|
||||
expect(Veganify.checkIngredientsListV1).toHaveBeenCalledWith(
|
||||
"apple",
|
||||
false
|
||||
);
|
||||
|
||||
// Test with staging undefined
|
||||
process.env.NEXT_PUBLIC_STAGING = undefined;
|
||||
await testFetchProduct("4000417025005");
|
||||
expect(Veganify.getProductByBarcode).toHaveBeenLastCalledWith(
|
||||
"4000417025005",
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("handles invalid response format", async () => {
|
||||
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValueOnce({
|
||||
invalidField: "invalid",
|
||||
});
|
||||
|
||||
await expect(testFetchProduct("4000417025005")).rejects.toThrow(
|
||||
"Invalid response format"
|
||||
);
|
||||
process.env.NEXT_PUBLIC_STAGING = originalEnv;
|
||||
});
|
||||
});
|
||||
|
|
|
@ -9,7 +9,7 @@ export async function checkIngredients(ingredients: string) {
|
|||
}
|
||||
|
||||
try {
|
||||
const data = await Veganify.checkIngredientsList(
|
||||
const data = await Veganify.checkIngredientsListV1(
|
||||
ingredients,
|
||||
process.env.NEXT_PUBLIC_STAGING === "true"
|
||||
);
|
||||
|
@ -18,7 +18,8 @@ export async function checkIngredients(ingredients: string) {
|
|||
vegan: data.data.vegan,
|
||||
surelyVegan: data.data.surely_vegan,
|
||||
notVegan: data.data.not_vegan,
|
||||
maybeVegan: data.data.maybe_vegan,
|
||||
maybeNotVegan: data.data.maybe_not_vegan,
|
||||
unknown: data.data.unknown,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
|
Loading…
Reference in a new issue