feat: Major refactor for performance improvements

This commit is contained in:
Philip 2024-10-26 16:54:44 +02:00
parent ebf2f4e714
commit 1b3bc1c9ba
37 changed files with 1383 additions and 1227 deletions

1
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1 @@
{}

View file

@ -16,11 +16,7 @@ let nextConfig = {
{
source: "/datenschutz",
destination: "/privacy-policy",
},
{
source: "/impressum",
destination: "/en/impressum",
},
}
];
},
};

18
public/img/ph_dark.svg Normal file
View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="250" height="54" viewBox="0 0 250 54" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g transform="translate(-130.000000, -73.000000)">
<g transform="translate(130.000000, 73.000000)">
<rect stroke="#221D21" stroke-width="1" fill="#221D21" x="0.5" y="0.5" width="249" height="53" rx="10"></rect>
<text font-family="Helvetica-Bold, Helvetica" font-size="9" font-weight="bold" fill="#EEF2FF">
<tspan x="53" y="20">#1 PRODUCT OF THE WEEK</tspan>
</text>
<text font-family="Helvetica-Bold, Helvetica" font-size="16" font-weight="bold" fill="#EEF2FF">
<tspan x="52" y="40">Health &amp; Fitness</tspan>
</text>
false
<g transform="translate(11.000000, 12.000000)"><path d="M31,15.5 C31,24.0603917 24.0603917,31 15.5,31 C6.93960833,31 0,24.0603917 0,15.5 C0,6.93960833 6.93960833,0 15.5,0 C24.0603917,0 31,6.93960833 31,15.5" fill="#FFFFFF"></path><path d="M17.4329412,15.9558824 L17.4329412,15.9560115 L13.0929412,15.9560115 L13.0929412,11.3060115 L17.4329412,11.3060115 L17.4329412,11.3058824 C18.7018806,11.3058824 19.7305882,12.3468365 19.7305882,13.6308824 C19.7305882,14.9149282 18.7018806,15.9558824 17.4329412,15.9558824 M17.4329412,8.20588235 L17.4329412,8.20601152 L10.0294118,8.20588235 L10.0294118,23.7058824 L13.0929412,23.7058824 L13.0929412,19.0560115 L17.4329412,19.0560115 L17.4329412,19.0558824 C20.3938424,19.0558824 22.7941176,16.6270324 22.7941176,13.6308824 C22.7941176,10.6347324 20.3938424,8.20588235 17.4329412,8.20588235" fill="#221D21"></path></g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

18
public/img/ph_light.svg Normal file
View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="250" height="54" viewBox="0 0 250 54" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g transform="translate(-130.000000, -73.000000)">
<g transform="translate(130.000000, 73.000000)">
<rect stroke="#FF6154" stroke-width="1" fill="#FFFFFF" x="0.5" y="0.5" width="249" height="53" rx="10"></rect>
<text font-family="Helvetica-Bold, Helvetica" font-size="9" font-weight="bold" fill="#FF6154">
<tspan x="53" y="20">#1 PRODUCT OF THE WEEK</tspan>
</text>
<text font-family="Helvetica-Bold, Helvetica" font-size="16" font-weight="bold" fill="#FF6154">
<tspan x="52" y="40">Health &amp; Fitness</tspan>
</text>
false
<g transform="translate(11.000000, 12.000000)"><path d="M31,15.5 C31,24.0603917 24.0603917,31 15.5,31 C6.93960833,31 0,24.0603917 0,15.5 C0,6.93960833 6.93960833,0 15.5,0 C24.0603917,0 31,6.93960833 31,15.5" fill="#FF6154"></path><path d="M17.4329412,15.9558824 L17.4329412,15.9560115 L13.0929412,15.9560115 L13.0929412,11.3060115 L17.4329412,11.3060115 L17.4329412,11.3058824 C18.7018806,11.3058824 19.7305882,12.3468365 19.7305882,13.6308824 C19.7305882,14.9149282 18.7018806,15.9558824 17.4329412,15.9558824 M17.4329412,8.20588235 L17.4329412,8.20601152 L10.0294118,8.20588235 L10.0294118,23.7058824 L13.0929412,23.7058824 L13.0929412,19.0560115 L17.4329412,19.0560115 L17.4329412,19.0558824 C20.3938424,19.0558824 22.7941176,16.6270324 22.7941176,13.6308824 C22.7941176,10.6347324 20.3938424,8.20588235 17.4329412,8.20588235" fill="#FFFFFF"></path></g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

18
public/img/ph_neutral.svg Normal file
View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="250" height="54" viewBox="0 0 250 54" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g transform="translate(-130.000000, -73.000000)">
<g transform="translate(130.000000, 73.000000)">
<rect stroke="#D9E1EC" stroke-width="1" fill="#FFFFFF" x="0.5" y="0.5" width="249" height="53" rx="10"></rect>
<text font-family="Helvetica-Bold, Helvetica" font-size="9" font-weight="bold" fill="#4B587C">
<tspan x="53" y="20">#1 PRODUCT OF THE WEEK</tspan>
</text>
<text font-family="Helvetica-Bold, Helvetica" font-size="16" font-weight="bold" fill="#4B587C">
<tspan x="52" y="40">Health &amp; Fitness</tspan>
</text>
false
<g transform="translate(11.000000, 12.000000)"><path d="M31,15.5 C31,24.0603917 24.0603917,31 15.5,31 C6.93960833,31 0,24.0603917 0,15.5 C0,6.93960833 6.93960833,0 15.5,0 C24.0603917,0 31,6.93960833 31,15.5" fill="#4B587C"></path><path d="M17.4329412,15.9558824 L17.4329412,15.9560115 L13.0929412,15.9560115 L13.0929412,11.3060115 L17.4329412,11.3060115 L17.4329412,11.3058824 C18.7018806,11.3058824 19.7305882,12.3468365 19.7305882,13.6308824 C19.7305882,14.9149282 18.7018806,15.9558824 17.4329412,15.9558824 M17.4329412,8.20588235 L17.4329412,8.20601152 L10.0294118,8.20588235 L10.0294118,23.7058824 L13.0929412,23.7058824 L13.0929412,19.0560115 L17.4329412,19.0560115 L17.4329412,19.0558824 C20.3938424,19.0558824 22.7941176,16.6270324 22.7941176,13.6308824 C22.7941176,10.6347324 20.3938424,8.20588235 17.4329412,8.20588235" fill="#FFFFFF"></path></g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -1,29 +1,28 @@
"use client";
import { useTranslations } from "next-intl";
import { useState, useEffect } from "react";
import { getTranslations } from "next-intl/server";
import Container from "@/components/elements/container";
import Nav from "@/components/nav";
export default function Impressum() {
const t = useTranslations();
const [impressum, setImpressum] = useState("");
async function getImpressum() {
try {
const response = await fetch("https://philipbrembeck.com/impressum.txt");
if (!response.ok) {
throw new Error(`Failed to fetch impressum: ${response.status}`);
}
return response.text();
} catch (error) {
console.error("Failed to fetch impressum:", error);
return "";
}
}
useEffect(() => {
fetch("https://philipbrembeck.com/impressum.txt")
.then((response) => response.text())
.then((text) => setImpressum(text))
.catch((error) => console.error(error));
}, []);
export default async function Impressum() {
const t = await getTranslations();
const impressum = await getImpressum();
return (
<>
<Nav />
<Container heading={t("More.imprint")}>
<p className="small">{t("Privacy.germanonly")}</p>
<div dangerouslySetInnerHTML={{ __html: impressum }} />
</Container>
</>
<Container heading={t("More.imprint")}>
<p className="small">{t("Privacy.germanonly")}</p>
<div dangerouslySetInnerHTML={{ __html: impressum }} />
</Container>
);
}

View file

@ -1,14 +1,11 @@
import Container from "@/components/elements/container";
import IngredientsCheck from "@/components/ingredientscheck";
import Nav from "@/components/nav";
import { IngredientsForm } from "@/components/IngredientsCheck/IngredientsForm";
export default function IngredientsPage() {
return (
<>
<div id="modal-root"></div>
<Nav />
<Container logo={false} backButton={false}>
<IngredientsCheck />
<IngredientsForm />
</Container>
</>
);

View file

@ -45,21 +45,15 @@ export const viewport: Viewport = {
viewportFit: "cover",
};
export default async function LocaleLayout(
props: {
children: ReactNode;
params: Promise<{ locale: string }>;
}
) {
export default async function LocaleLayout(props: {
children: ReactNode;
params: Promise<{ locale: string }>;
}) {
const params = await props.params;
const {
locale
} = params;
const { locale } = params;
const {
children
} = props;
const { children } = props;
const messages = await getMessages();
@ -67,7 +61,7 @@ export default async function LocaleLayout(
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
<div id="modal-root"></div>
<div id="modal-root" />
<Nav />
{children}
</NextIntlClientProvider>

View file

@ -7,9 +7,17 @@ import { setCookie } from "nookies";
import Container from "@/components/elements/container";
import SupportOption from "@/components/elements/contents/donate";
import ModalWrapper from "@/components/elements/modalwrapper";
import Nav from "@/components/nav";
import { Link } from "@/i18n/routing";
const languages = [
{ code: "en", name: "english" },
{ code: "de", name: "german" },
{ code: "es", name: "spanish" },
{ code: "fr", name: "french" },
{ code: "pl", name: "polish" },
{ code: "cz", name: "czech" },
] as const;
export default function More() {
const t = useTranslations("More");
const currentLocale = useLocale();
@ -22,156 +30,149 @@ export default function More() {
}
return (
<>
<div id="modal-root"></div>
<Nav />
<Container logo={false} backButton={false}>
<div className="Grid links">
<ModalWrapper
id="donate"
buttonType="div"
buttonClass="Grid-cell description"
buttonText={t("buyusacoffee")}
>
<SupportOption />
</ModalWrapper>
<div className="Grid-cell icons">
<span
className="unknown icon-right-open"
data-target="donationmodal"
data-toggle="modal"
></span>
</div>
<Container logo={false} backButton={false}>
<div className="Grid links">
<ModalWrapper
id="donate"
buttonType="div"
buttonClass="Grid-cell description"
buttonText={t("buyusacoffee")}
>
<SupportOption />
</ModalWrapper>
<div className="Grid-cell icons">
<span
className="unknown icon-right-open"
data-target="donationmodal"
data-toggle="modal"
/>
</div>
<div className="Grid links">
<ModalWrapper
id="follow"
buttonType="div"
buttonClass="Grid-cell description"
buttonText={t("followus")}
</div>
<div className="Grid links">
<ModalWrapper
id="follow"
buttonType="div"
buttonClass="Grid-cell description"
buttonText={t("followus")}
>
<span className="center">
<Image
src="/img/follow_img.svg"
className="heading_img"
alt="Follow us"
width={48}
height={48}
/>
<h1>{t("followus")}</h1>
</span>
<a
href="https://veganism.social/@vegancheck"
rel="me"
className="menu twitter"
>
<span className="center">
<Image
src="/img/follow_img.svg"
className="heading_img"
alt="Follow us"
width={48}
height={48}
/>
<h1>{t("followus")}</h1>
</span>
<a
href="https://veganism.social/@vegancheck"
rel="me"
className="menu twitter"
<span className="label">Mastodon</span>
<div className="social-icon">
<span className="icon-mastodon" />
</div>
</a>
<a href="https://instagram.com/veganify.app" className="menu last">
<span className="label">Instagram</span>
<div className="social-icon">
<span className="icon-instagram" />
</div>
</a>
</ModalWrapper>
<div className="Grid-cell icons">
<span
className="unknown icon-right-open"
data-target="donationmodal"
data-toggle="modal"
/>
</div>
</div>
<Link href="/tos" className="Grid links">
<div className="Grid-cell description">{t("tos")}</div>
<div className="Grid-cell icons">
<span className="unknown icon-right-open" />
</div>
</Link>
<Link href="privacy-policy" className="Grid links">
<div className="Grid-cell description">{t("privacypolicy")}</div>
<div className="Grid-cell icons">
<span className="unknown icon-right-open" />
</div>
</Link>
<a href="https://frontendnet.work/veganify-api" className="Grid links">
<div className="Grid-cell description">{t("apidocumentation")}</div>
<div className="Grid-cell icons">
<span className="unknown icon-right-open" />
</div>
</a>
<Link href="impressum" className="Grid links">
<div className="Grid-cell description">{t("imprint")}</div>
<div className="Grid-cell icons">
<span className="unknown icon-right-open" />
</div>
</Link>
<div className="Grid links">
<ModalWrapper
id="language"
buttonType="div"
buttonClass="Grid-cell description"
buttonText={t("language")}
>
<span className="center">
<Image
src="/img/language_img.svg"
className="heading_img"
alt="Language"
width={48}
height={48}
/>
<h1>{t("language")}</h1>
</span>
{languages.map(({ code, name }) => (
<Link
key={code}
className="nolink"
href={`/more`}
locale={
code as "en" | "de" | "es" | "fr" | "pl" | "cz" | undefined
}
onClick={() => handleLanguageChange(code)}
>
<span className="label">Mastodon</span>
<div className="social-icon">
<span className="icon-mastodon"></span>
</div>
</a>
<a href="https://instagram.com/veganify.app" className="menu">
<span className="label">Instagram</span>
<div className="social-icon">
<span className="icon-instagram"></span>
</div>
</a>
</ModalWrapper>
<div className="Grid-cell icons">
<span
className="unknown icon-right-open"
data-target="donationmodal"
data-toggle="modal"
></span>
</div>
</div>
<Link href="/tos" className="Grid links">
<div className="Grid-cell description">{t("tos")}</div>
<div className="Grid-cell icons">
<span className="unknown icon-right-open"></span>
</div>
</Link>
<Link href="privacy-policy" className="Grid links">
<div className="Grid-cell description">{t("privacypolicy")}</div>
<div className="Grid-cell icons">
<span className="unknown icon-right-open"></span>
</div>
</Link>
<a href="https://frontendnet.work/veganify-api" className="Grid links">
<div className="Grid-cell description">{t("apidocumentation")}</div>
<div className="Grid-cell icons">
<span className="unknown icon-right-open"></span>
</div>
</a>
<Link href="impressum" className="Grid links">
<div className="Grid-cell description">{t("imprint")}</div>
<div className="Grid-cell icons">
<span className="unknown icon-right-open"></span>
</div>
</Link>
<div className="Grid links">
<ModalWrapper
id="follow"
buttonType="div"
buttonClass="Grid-cell description"
buttonText={t("language")}
>
<span className="center">
<Image
src="/img/language_img.svg"
className="heading_img"
alt="Language"
width={48}
height={48}
/>
<h1>{t("language")}</h1>
</span>
{[
{ code: "en", name: "english" },
{ code: "de", name: "german" },
{ code: "es", name: "spanish" },
{ code: "fr", name: "french" },
{ code: "pl", name: "polish" },
{ code: "cz", name: "czech" },
].map(({ code, name }) => (
<Link
key={code}
className="nolink"
href={`/more`}
locale={
code as "en" | "de" | "es" | "fr" | "pl" | "cz" | undefined
}
onClick={() => handleLanguageChange(code)}
<div
className={currentLocale === code ? "option active" : "option"}
>
<div
className={
currentLocale === code ? "option active" : "option"
}
>
<input
className="form-check-input"
type="radio"
name="flexRadioDefault"
checked={currentLocale === code}
readOnly
/>
<span className="price">{t(name)}</span>
</div>
</Link>
))}
<span className="info" id="cookieinfo">
{t("thissetsacookie")}
</span>
</ModalWrapper>
<div className="Grid-cell icons">
<span
className="unknown icon-right-open"
data-target="donationmodal"
data-toggle="modal"
></span>
</div>
<input
className="form-check-input"
type="radio"
name="flexRadioDefault"
checked={currentLocale === code}
readOnly
/>
<span className="price">{t(name)}</span>
</div>
</Link>
))}
<span className="info" id="cookieinfo">
{t("thissetsacookie")}
</span>
</ModalWrapper>
<div className="Grid-cell icons">
<span
className="unknown icon-right-open"
data-target="donationmodal"
data-toggle="modal"
/>
</div>
</Container>
</>
</div>
</Container>
);
}

View file

@ -1,6 +1,6 @@
import { Metadata } from "next";
import ProductSearch from "@/components/check";
import ProductSearch from "@/components/Check/index";
import InstallPrompt from "@/components/elements/pwainstall";
import Shortcut from "@/components/elements/shortcutinstall";
import Footer from "@/components/footer";
@ -14,7 +14,6 @@ export const metadata: Metadata = {
export default function Home() {
return (
<>
<div id="modal-root"></div>
<InstallPrompt />
<Shortcut />
<Nav />

View file

@ -1,32 +1,31 @@
"use client";
import { useTranslations } from "next-intl";
import { useState, useEffect } from "react";
import { getTranslations } from "next-intl/server";
import Container from "@/components/elements/container";
import Nav from "@/components/nav";
export default function PrivacyPolicy() {
const t = useTranslations("Privacy");
const [datenschutz, setDatenschutz] = useState("");
async function getPrivacyPolicy() {
try {
const response = await fetch("https://philipbrembeck.com/datenschutz.txt");
if (!response.ok) {
throw new Error(`Failed to fetch privacy policy: ${response.status}`);
}
return response.text();
} catch (error) {
console.error("Failed to fetch privacy policy:", error);
return "";
}
}
useEffect(() => {
fetch("https://philipbrembeck.com/datenschutz.txt")
.then((response) => response.text())
.then((text) => setDatenschutz(text))
.catch((error) => console.error(error));
}, []);
export default async function PrivacyPolicy() {
const t = await getTranslations("Privacy");
const datenschutz = await getPrivacyPolicy();
return (
<>
<Nav />
<Container>
<p className="small">{t("germanonly")}</p>
<div
className="privacy"
dangerouslySetInnerHTML={{ __html: datenschutz }}
/>
</Container>
</>
<Container>
<p className="small">{t("germanonly")}</p>
<div
className="privacy"
dangerouslySetInnerHTML={{ __html: datenschutz }}
/>
</Container>
);
}

View file

@ -1,13 +1,11 @@
import { useTranslations } from "next-intl";
import Container from "@/components/elements/container";
import Nav from "@/components/nav";
export default function TOS() {
const t = useTranslations("TOS");
return (
<>
<Nav />
<Container heading={t("tos")}>
<p className="small">{t("englishgermanonly")}</p>
<div dangerouslySetInnerHTML={{ __html: t.raw("tos_content") }} />

View file

@ -0,0 +1,53 @@
"use client";
import { useTranslations } from "next-intl";
export function LoadingSkeleton() {
const t = useTranslations("Check");
return (
<div id="result" className="loading_skeleton">
<div className="animated fadeIn resultborder" id="RSFound">
<span className="unknown">
<span className="name skeleton">&nbsp;</span>
</span>
<span id="result_sh">
<div className="Grid">
<div className="Grid-cell description skeleton">{t("vegan")}</div>
<div className="Grid-cell icons skeleton">
<span className="icon-help"></span>
</div>
</div>
</span>
<div className="Grid">
<div className="Grid-cell description skeleton">
{t("vegetarian")}
</div>
<div className="Grid-cell icons skeleton">
<span className="icon-help"></span>
</div>
</div>
<div className="Grid">
<div className="Grid-cell description skeleton">{t("palmoil")}</div>
<div className="Grid-cell icons skeleton">
<span className="icon-help"></span>
</div>
</div>
<div className="Grid">
<div className="Grid-cell description skeleton">Nutriscore</div>
<div className="Grid-cell icons skeleton">
<span className="icon-help"></span>
</div>
</div>
<div className="Grid">
<div className="Grid-cell description skeleton">Grade</div>
<div className="Grid-cell icons skeleton">
<span className="icon-help"></span>
</div>
</div>
<span className="source skeleton">&nbsp;</span>
<span className="button skeleton">{t("share")}</span>
</div>
</div>
);
}

View file

@ -0,0 +1,211 @@
"use client";
import Image from "next/image";
import { useTranslations } from "next-intl";
import ModalWrapper from "@/components/elements/modalwrapper";
import ShareButton from "@/components/elements/share";
import { ProductResult } from "@/models/ProductResults";
import { Sources } from "@/models/Sources";
import { ProductState } from "./models/product";
import { ResultGrid } from "./ResultGrid";
interface ProductResultProps {
result: ProductResult;
sources: Sources;
productState: ProductState;
barcode: string;
}
export function ProductResultView({
result,
sources,
productState,
barcode,
}: ProductResultProps) {
const t = useTranslations("Check");
const productname = result.productname === "n/a" ? "?" : result.productname;
return (
<div id="result">
<div className="resultborder" id="RSFound">
<span className="unknown">
<span className="name" id="name_sh">
{productname}
</span>
</span>
<ResultGrid label={t("vegan")} iconClass={productState.vegan} />
<ResultGrid
label={t("vegetarian")}
iconClass={productState.vegetarian}
/>
<ResultGrid
label={t("palmoil")}
iconClass={productState.palmoil}
helpModal={
<ModalWrapper
id="palmoil"
buttonType="sup"
buttonClass="help-icon"
buttonText="?"
>
<span className="center">
<Image
src="../img/palmoil_img.svg"
className="heading_img"
alt="Palmoil"
width={48}
height={48}
/>
<h1>{t("palmoil")}</h1>
</span>
<p>{t("palmoil_desc")}</p>
</ModalWrapper>
}
/>
{productState.animaltestfree !== "unknown icon-help" && (
<ResultGrid
label={t("crueltyfree")}
iconClass={productState.animaltestfree}
/>
)}
<ResultGrid
label="Nutriscore"
iconClass={productState.nutriscore.score}
helpModal={
<ModalWrapper
id="nutriscore"
buttonType="sup"
buttonClass="help-icon"
buttonText="?"
>
<span className="center">
<Image
src="../img/nutriscore_image.svg"
className="heading_img"
alt="Nutriscore"
width={48}
height={48}
/>
<h1>Nutriscore</h1>
</span>
<p
dangerouslySetInnerHTML={{
__html: t("nutriscore_desc", {
Algorithmwatch:
'<a href="https://algorithmwatch.org/en/nutriscore/">Algorithmwatch</a>',
}),
}}
/>
</ModalWrapper>
}
/>
<ResultGrid
label="Grade"
iconClass={productState.grade.score}
helpModal={
<ModalWrapper
id="grade"
buttonType="sup"
buttonClass="help-icon"
buttonText="?"
>
<span className="center">
<Image
src="../img/grade_img.svg"
className="heading_img"
alt="Grades"
width={48}
height={48}
/>
<h1>Grades</h1>
</span>
<p
dangerouslySetInnerHTML={{
__html: t("grades_desc", {
Grades:
'<a href="https://grade.veganify.app">Veganify Grades</a>',
}),
}}
/>
<span className="center">
<a href="https://grade.veganify.app" className="button">
Veganify Grades
</a>
</span>
</ModalWrapper>
}
/>
<span className="source">
{t("source")}:{" "}
<a href={sources.baseuri} className="RSSource" target="_blank">
{sources.api}
</a>
<ModalWrapper
id="license"
buttonType="sup"
buttonClass="help-icon"
buttonText="?"
>
<span className="center">
<Image
src="../img/license_img.svg"
className="heading_img"
alt="Licenses"
width={48}
height={48}
/>
<h1>{t("licenses")}</h1>
</span>
<p>{t("licenses_desc")}</p>
<p>
&copy; OpenFoodFacts Contributors, licensed under{" "}
<a href="https://opendatacommons.org/licenses/odbl/1.0/">
Open Database License
</a>{" "}
and{" "}
<a href="https://opendatacommons.org/licenses/dbcl/1.0/">
Database Contents License
</a>
.<br />
&copy; Open EAN/GTIN Database Contributors, licensed under{" "}
<a href="https://www.gnu.org/licenses/fdl-1.3.html">GNU FDL</a>
.<br />
&copy; Veganify Contributors and Hamed Montazeri, licensed under{" "}
<a href="https://github.com/JokeNetwork/vegan-ingredients-api/blob/master/LICENSE">
MIT License
</a>
, sourced from{" "}
<a href="https://www.veganpeace.com/ingredients/ingredients.htm">
VeganPeace
</a>
,{" "}
<a href="https://www.peta.org/living/food/animal-ingredients-list/">
PETA
</a>{" "}
and{" "}
<a href="https://www.veganwolf.com/animal_ingredients.htm">
The VEGAN WOLF
</a>
.<br />
&copy; Veganify Contributors, sourced from ©{" "}
<a href="https://crueltyfree.peta.org">
PETA (Beauty without Bunnies)
</a>
.
</p>
</ModalWrapper>
</span>
<ShareButton productName={productname} barcode={barcode} />
</div>
</div>
);
}

View file

@ -0,0 +1,21 @@
import { ReactNode } from "react";
interface ResultGridProps {
label: string;
iconClass: string;
helpModal?: ReactNode;
}
export function ResultGrid({ label, iconClass, helpModal }: ResultGridProps) {
return (
<div className="Grid">
<div className="Grid-cell description">
{label}
{helpModal}
</div>
<div className="Grid-cell icons">
<span className={iconClass}></span>
</div>
</div>
);
}

View file

@ -0,0 +1,59 @@
"use client";
import Image from "next/image";
import { useTranslations } from "next-intl";
import ScanButton from "@/components/Scanner";
interface SearchFormProps {
barcode: string;
loading: boolean;
onBarcodeChange: (barcode: string) => void;
onSubmit: (barcode: string, e?: React.FormEvent) => void;
}
export function SearchForm({
barcode,
loading,
onBarcodeChange,
onSubmit,
}: SearchFormProps) {
const t = useTranslations("Check");
return (
<>
<Image
src="/./img/Veganify.svg"
alt="Logo"
className={`logo ${loading ? "spinner" : ""}`}
width={48}
height={48}
/>
<form onSubmit={(e) => onSubmit(barcode, e)}>
<legend>{t("enterbarcode")}</legend>
<fieldset>
<legend>{t("enterbarcode")}</legend>
<ScanButton
onDetected={onBarcodeChange}
handleSubmit={(barcode) => onSubmit(barcode)}
/>
<label htmlFor="barcodeInput" className="hidden">
{t("enterbarcode")}
</label>
<input
type="number"
name="barcode"
id="barcodeInput"
placeholder={t("enterbarcode")}
autoFocus={true}
value={barcode}
onChange={(e) => onBarcodeChange(e.target.value)}
/>
<button name="submit" aria-label={t("submit")} role="button">
<span className="icon-right-open" />
</button>
</fieldset>
</form>
</>
);
}

View file

@ -0,0 +1,73 @@
"use client";
import { useTranslations } from "next-intl";
interface StatusMessagesProps {
showNotFound: boolean;
showInvalid: boolean;
showTimeout: boolean;
showTimeoutFinal: boolean;
}
export function StatusMessages({
showNotFound,
showInvalid,
showTimeout,
showTimeoutFinal,
}: StatusMessagesProps) {
const t = useTranslations("Check");
if (showNotFound) {
return (
<div id="result">
<div className="resultborder" id="RSNotFound">
<span>{t("notindb")}</span>
<p className="missing" style={{ textAlign: "center" }}>
{t("notindb_add")}{" "}
<a
href="https://world.openfoodfacts.org/cgi/product.pl"
target="_blank"
>
{t("add_food")}
</a>{" "}
{t("or")}{" "}
<a
href="https://world.openbeautyfacts.org/cgi/product.pl"
target="_blank"
>
{t("add_cosmetic")}
</a>
.
</p>
</div>
</div>
);
}
if (showInvalid) {
return (
<div id="result">
<div className="resultborder" id="RSInvalid">
<span>{t("wrongbarcode")}</span>
</div>
</div>
);
}
if (showTimeout) {
return (
<div className="timeout animated fadeIn">
{t("timeout1")}
<span>.</span>
<span>.</span>
<span>.</span>
</div>
);
}
if (showTimeoutFinal) {
return <div className="timeout-final animated fadeIn">{t("timeout2")}</div>;
}
return null;
}

View file

@ -0,0 +1,114 @@
"use client";
import { useState, useEffect } from "react";
import { ErrorResponse } from "@/models/ErrorRepsonse";
import { ProductResult } from "@/models/ProductResults";
import { Sources } from "@/models/Sources";
import { LoadingSkeleton } from "./LoadingSkeleton";
import { ProductResultView } from "./ProductResult";
import { SearchForm } from "./SearchForm";
import { StatusMessages } from "./StatusMessages";
import { fetchProduct } from "./utils/product-actions";
import { getProductState } from "./utils/product-helpers";
export default function ProductSearch() {
const [result, setResult] = useState<ProductResult>({
productname: "",
vegan: "n/a",
vegetarian: "n/a",
animaltestfree: "n/a",
palmoil: "n/a",
nutriscore: "",
grade: "",
});
const [sources, setSources] = useState<Sources>({});
const [barcode, setBarcode] = useState<string>("");
const [showFound, setShowFound] = useState<boolean>(false);
const [showNotFound, setShowNotFound] = useState<boolean>(false);
const [showInvalid, setShowInvalid] = useState<boolean>(false);
const [showTimeout, setShowTimeout] = useState<boolean>(false);
const [showTimeoutFinal, setShowTimeoutFinal] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const eanFromURL = params.get("ean");
if (eanFromURL) {
setBarcode(eanFromURL);
handleSubmit(eanFromURL);
}
}, []);
const handleSubmit = async (barcode: string, event?: React.FormEvent) => {
event?.preventDefault();
setShowTimeoutFinal(false);
setShowTimeout(false);
setShowFound(false);
setShowNotFound(false);
setShowInvalid(false);
setLoading(true);
try {
const data = await fetchProduct(barcode);
if (data.status === 200 && data.product && data.sources) {
setResult(data.product);
setSources(data.sources);
setShowFound(true);
} else if (data.status === 404) {
setShowNotFound(true);
} else {
setShowInvalid(true);
}
} catch (error) {
if (
typeof error === "object" &&
error !== null &&
"response" in error &&
"status" in (error as ErrorResponse).response
) {
if ((error as ErrorResponse).response.status === 400) {
setShowInvalid(true);
} else if ((error as ErrorResponse).response.status === 404) {
setShowNotFound(true);
}
} else {
console.error(error);
setShowTimeoutFinal(true);
}
} finally {
setLoading(false);
}
};
return (
<>
<SearchForm
barcode={barcode}
loading={loading}
onBarcodeChange={setBarcode}
onSubmit={handleSubmit}
/>
{showFound && (
<ProductResultView
result={result}
sources={sources}
productState={getProductState(result)}
barcode={barcode}
/>
)}
<StatusMessages
showNotFound={showNotFound}
showInvalid={showInvalid}
showTimeout={showTimeout}
showTimeoutFinal={showTimeoutFinal}
/>
{loading && <LoadingSkeleton />}
</>
);
}

View file

@ -0,0 +1,13 @@
export interface NutriscoreGrade {
score: string;
className: string;
}
export interface ProductState {
vegan: string;
vegetarian: string;
animaltestfree: string;
palmoil: string;
nutriscore: NutriscoreGrade;
grade: NutriscoreGrade;
}

View file

@ -0,0 +1,34 @@
"use server";
"use cache";
import Veganify from "@frontendnetwork/veganify";
import { ProductResult } from "@/models/ProductResults";
import { Sources } from "@/models/Sources";
export async function fetchProduct(barcode: string): Promise<{
product?: ProductResult;
sources?: Sources;
status: number;
}> {
try {
const data = await Veganify.getProductByBarcode(
barcode,
process.env.NEXT_PUBLIC_STAGING === "true"
);
if ("product" in data && "sources" in data) {
return {
product: data.product,
sources: data.sources,
status: data.status,
};
} else {
return {
status: data.status,
};
}
} catch (error) {
console.error("Product fetch error:", error);
throw error;
}
}

View file

@ -0,0 +1,34 @@
import { ProductResult } from "@/models/ProductResults";
import { NutriscoreGrade, ProductState } from "../models/product";
export function getProductState(result: ProductResult): ProductState {
const getVeganState = (value: boolean | "n/a" | undefined): string => {
if (value === true) return "vegan icon-ok";
if (value === false) return "non-vegan icon-cancel";
return "unknown icon-help";
};
const getNutriscoreClass = (score: string | undefined): NutriscoreGrade => {
if (!score || score === "n/a")
return { score: "unknown icon-help", className: "" };
const normalizedScore = score.toLowerCase();
if (["a", "b", "c", "d", "e"].includes(normalizedScore)) {
return {
score: `nutri_${normalizedScore} icon-${normalizedScore}`,
className: `nutri_${normalizedScore}`,
};
}
return { score: "unknown icon-help", className: "" };
};
return {
vegan: getVeganState(result.vegan),
vegetarian: getVeganState(result.vegetarian),
animaltestfree: getVeganState(result.animaltestfree),
palmoil: getVeganState(result.palmoil),
nutriscore: getNutriscoreClass(result.nutriscore),
grade: getNutriscoreClass(result.grade),
};
}

View file

@ -0,0 +1,109 @@
"use client";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { useState } from "react";
import { IngredientResult } from "./models/IngredientResult";
import { ResultDisplay } from "./ResultsDisplay";
import { checkIngredients } from "./utils/actions";
export function IngredientsForm() {
const t = useTranslations("Ingredients");
const [result, setResult] = useState<IngredientResult>({
vegan: null,
surelyVegan: [],
notVegan: [],
maybeVegan: [],
});
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setResult({ vegan: null, surelyVegan: [], notVegan: [], maybeVegan: [] });
setError(null);
const formData = new FormData(event.currentTarget);
const ingredients = formData.get("ingredients") as string;
if (!ingredients.trim()) {
setError(t("cannotbeempty"));
return;
}
setLoading(true);
try {
const data = await checkIngredients(ingredients);
setResult(data);
} catch (error) {
setError(t("cannotbeempty"));
} finally {
setLoading(false);
}
}
return (
<>
<Image
src="/./img/Veganify.svg"
alt="Logo"
className={`logo ${loading ? "spinner" : ""}`}
width={48}
height={48}
/>
<h2 style={{ textAlign: "center", marginTop: "0" }}>
{t("ingredientcheck")}
</h2>
<p style={{ textAlign: "center" }}>{t("ingredientcheck_desc")}</p>
<form onSubmit={handleSubmit}>
<fieldset>
<legend>{t("entercommaseperated")}</legend>
<textarea
id="ingredients"
name="ingredients"
placeholder={t("entercommaseperated")}
/>
<button
type="submit"
name="checkingredients"
aria-label={t("submit")}
>
<span className="icon-right-open"></span>
</button>
</fieldset>
</form>
{result.vegan !== null && <ResultDisplay result={result} t={t} />}
{error && (
<div id="result">
<span className="animated fadeIn">
<div className="resultborder">{error}</div>
</span>
</div>
)}
{loading && (
<div id="result" className="loading_skeleton">
<div className="animated fadeIn">
<div className="resultborder">
<div className="Grid">
<div className="Grid-cell description skeleton">
<b>{t("vegan")}</b>
</div>
<div className="Grid-cell icons skeleton">
<span className="icon-help"></span>
</div>
</div>
<span className="source skeleton">&nbsp;</span>
<span className="source skeleton">&nbsp;</span>
<span className="source skeleton">&nbsp;</span>
</div>
</div>
</div>
)}
</>
);
}

View file

@ -0,0 +1,24 @@
import React from "react";
export function IngredientList({
items,
iconClass,
}: {
items: string[];
iconClass: string;
}) {
return (
<>
{items.map((item) => (
<div className="Grid" key={item}>
<div className="Grid-cell description">
{item.charAt(0).toUpperCase() + item.slice(1)}
</div>
<div className="Grid-cell icons">
<span className={iconClass}></span>
</div>
</div>
))}
</>
);
}

View file

@ -0,0 +1,45 @@
import { IngredientList } from "./IngredientsList";
import { IngredientResult } from "./models/IngredientResult";
import { SourceInfo } from "./SourceInfo";
export function ResultDisplay({
result,
t,
}: {
result: IngredientResult;
t: (key: string, values?: Record<string, string>) => string;
}) {
return (
<div id="result">
<div className="">
<div className="resultborder">
<div className="Grid">
<div className="Grid-cell description">
<b>{t("vegan")}</b>
</div>
<div className="Grid-cell icons">
<span
className={
result.vegan ? "vegan icon-ok" : "non-vegan icon-cancel"
}
></span>
</div>
</div>
<IngredientList
items={result.notVegan}
iconClass="non-vegan icon-cancel"
/>
<IngredientList
items={result.maybeVegan}
iconClass="maybe-vegan icon-help"
/>
<IngredientList
items={result.surelyVegan}
iconClass="vegan icon-ok"
/>
<SourceInfo t={t} />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,55 @@
"use client";
import Image from "next/image";
import ModalWrapper from "@/components/elements/modalwrapper";
export function SourceInfo({
t,
}: {
t: (key: string, values?: Record<string, string>) => string;
}) {
return (
<span className="source">
{t("source")}:{" "}
<a href="https://www.veganpeace.com/ingredients/ingredients.htm">
VeganPeace
</a>
,{" "}
<a href="https://www.peta.org/living/food/animal-ingredients-list/">
PETA
</a>{" "}
&amp;{" "}
<a href="https://www.veganwolf.com/animal_ingredients.htm">
The VEGAN WOLF
</a>
<ModalWrapper
id="license"
buttonType="sup"
buttonClass="help-icon"
buttonText="?"
>
<span className="center">
<Image
src="../img/license_img.svg"
className="heading_img"
alt="Licenses"
width={48}
height={48}
/>
<h1>{t("licenses")}</h1>
</span>
<p>{t("licenses_desc")}</p>
{/* License text... */}
</ModalWrapper>
<br />
<span
dangerouslySetInnerHTML={{
__html: t("languagewarning", {
deepl: '<a href="https://deepl.com">DeepL</a>',
}),
}}
/>
</span>
);
}

View file

@ -0,0 +1,6 @@
export interface IngredientResult {
vegan: boolean | null;
surelyVegan: string[];
notVegan: string[];
maybeVegan: string[];
}

View file

@ -0,0 +1,26 @@
"use server";
"use cache";
import Veganify from "@frontendnetwork/veganify";
export async function checkIngredients(ingredients: string) {
if (!ingredients.trim()) {
throw new Error("Ingredients cannot be empty");
}
try {
const data = await Veganify.checkIngredientsList(
ingredients,
process.env.NEXT_PUBLIC_STAGING === "true"
);
return {
vegan: data.data.vegan,
surelyVegan: data.data.surely_vegan,
notVegan: data.data.not_vegan,
maybeVegan: data.data.maybe_vegan,
};
} catch (error) {
throw new Error("Failed to check ingredients");
}
}

View file

@ -0,0 +1,41 @@
"use client";
import { useState } from "react";
import { DetectionResult } from "./models/scanner";
import { ViewportScanner } from "./ViewportScanner";
interface ScanButtonProps {
onDetected: (barcode: string) => void;
handleSubmit: (barcode: string, obj: object) => void;
}
export function ScanButton({ onDetected, handleSubmit }: ScanButtonProps) {
const [scanning, setScanning] = useState(false);
const handleDetection = (result: DetectionResult) => {
const barcode = result.codeResult.code;
setScanning(false);
onDetected(barcode);
handleSubmit(barcode, {});
};
return (
<>
<button
type="button"
aria-label="Barcode scannen"
onClick={() => setScanning(true)}
>
<span className="icon-barcode" />
</button>
{scanning && (
<ViewportScanner
onDetected={handleDetection}
setScanning={setScanning}
/>
)}
</>
);
}

View file

@ -0,0 +1,129 @@
"use client";
import Quagga from "@ericblade/quagga2";
import { CSSProperties, useEffect, useState } from "react";
import { ScannerProps } from "./models/scanner";
export function ViewportScanner({ onDetected, setScanning }: ScannerProps) {
const [facingMode, setFacingMode] = useState("user");
const [isHidden, setIsHidden] = useState(false);
const [isMirrored, setIsMirrored] = useState(true);
const initializeScanner = (newFacingMode: string) => {
const width = window.innerWidth;
const height = window.innerHeight;
Quagga.init(
{
inputStream: {
type: "LiveStream",
constraints: {
aspectRatio: { ideal: height / width },
facingMode: newFacingMode,
height: { min: 480, ideal: height, max: 1080 },
},
},
locator: {
patchSize: "medium",
halfSample: true,
},
numOfWorkers: 2,
decoder: {
readers: [
"ean_reader",
"code_39_reader",
"code_128_reader",
"i2of5_reader",
],
},
locate: true,
},
(err: Error | null) => {
if (err) {
console.error("Error initializing Quagga:", err);
return;
}
Quagga.start();
}
);
};
const handleCameraSwitch = () => {
const newFacingMode = facingMode === "environment" ? "user" : "environment";
const newIsMirrored = newFacingMode === "user";
setFacingMode(newFacingMode);
setIsMirrored(newIsMirrored);
Quagga.stop();
initializeScanner(newFacingMode);
};
const handleClose = () => {
setIsHidden(true);
setScanning(false);
Quagga.stop();
};
useEffect(() => {
initializeScanner(facingMode);
Quagga.onDetected(onDetected);
return () => {
Quagga.offDetected(onDetected);
Quagga.stop();
};
// Disabled here, as we only want to run this once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (isHidden) return null;
const viewportStyle: CSSProperties = {
position: "fixed",
zIndex: 999,
left: "50%",
top: 0,
transform: isMirrored ? "translateX(-50%) scaleX(-1)" : "translateX(-50%)",
};
const backdropStyle: CSSProperties = {
position: "fixed",
zIndex: 998,
top: 0,
left: 0,
width: "100%",
height: "100%",
background: "rgba(0, 0, 0, 0.2)",
backdropFilter: "blur(0.5rem)",
WebkitBackdropFilter: "blur(0.5rem)",
};
return (
<>
<div style={backdropStyle} />
<div id="controls">
<span id="close">
<div className="flex-container">
<div className="flex-item">
<span
id="closebtn"
className="icon-left-open"
onClick={handleClose}
/>
</div>
<div className="flex-item">
<span
id="switch-camera"
className="icon-flipcamera"
onClick={handleCameraSwitch}
/>
</div>
</div>
</span>
</div>
<div id="interactive" className="viewport" style={viewportStyle} />
</>
);
}

View file

@ -0,0 +1 @@
export { ScanButton as default } from "./ScanButton";

View file

@ -0,0 +1,10 @@
export interface ScannerProps {
onDetected: (result: DetectionResult) => void;
setScanning: (scanning: boolean) => void;
}
export interface DetectionResult {
codeResult: {
code: string;
};
}

View file

@ -1,218 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unused-vars */
import Quagga from "@ericblade/quagga2";
import React, { Component } from "react";
interface ScannerProps {
onDetected: (result: any) => void;
setScanning: (scanning: boolean) => void;
}
interface ScannerState {
facingMode: string;
isHidden: boolean;
isMirrored: boolean;
}
class Scanner extends Component<ScannerProps, ScannerState> {
state: ScannerState = {
facingMode: "user",
isHidden: false,
isMirrored: true,
};
handleClick = () => {
const { facingMode } = this.state;
const newFacingMode = facingMode === "environment" ? "user" : "environment";
const isMirrored = newFacingMode === "user";
this.setState({ facingMode: newFacingMode, isMirrored });
const width = window.innerWidth;
const height = window.innerHeight;
Quagga.init(
{
inputStream: {
type: "LiveStream",
constraints: {
aspectRatio: { ideal: height / width },
facingMode: newFacingMode,
height: { min: 480, ideal: height, max: 1080 },
},
},
locator: {
patchSize: "medium",
halfSample: true,
},
numOfWorkers: 2,
decoder: {
readers: [
"ean_reader",
"code_39_reader",
"code_128_reader",
"i2of5_reader",
],
},
locate: true,
},
(err: any) => {
if (err) {
return console.log(err);
}
Quagga.start();
}
);
};
componentDidMount() {
const { facingMode } = this.state;
console.log(facingMode);
this.handleClick();
Quagga.onDetected(this._onDetected);
}
componentWillUnmount() {
Quagga.offDetected(this._onDetected);
}
_onDetected = (result: any) => {
const { onDetected } = this.props;
onDetected(result);
Quagga.stop();
};
_onClose = () => {
const { isHidden } = this.state;
this.setState({ isHidden: !isHidden });
const { setScanning } = this.props;
setScanning(false);
Quagga.stop();
};
render() {
const { isHidden, isMirrored } = this.state;
const vid: React.CSSProperties = {
position: "fixed",
zIndex: 999,
left: "50%",
top: 0,
transform: isMirrored ? "translateX(-50%) scaleX(-1)" : "translateX(-50%)",
};
const backdrop: React.CSSProperties = {
position: "fixed",
zIndex: 998,
top: 0,
left: 0,
width: "100%",
height: "100%",
background: "rgba(0, 0, 0, 0.2)",
backdropFilter: "blur(0.5rem)",
WebkitBackdropFilter: "blur(0.5rem)",
};
return (
!isHidden && (
<>
<div style={backdrop}></div>
<div id="controls">
<span id="close">
<div className="flex-container">
<div className="flex-item">
<span
id="closebtn"
className="icon-left-open"
onClick={this._onClose}
></span>
</div>
<div className="flex-item">
<span
id="switch-camera"
className="icon-flipcamera"
onClick={this.handleClick}
></span>
</div>
</div>
</span>
</div>
<div id="interactive" className="viewport" style={vid}></div>
</>
)
);
}
}
interface ResultProps {
result: any;
}
class Result extends Component<ResultProps> {
render() {
const { result } = this.props;
if (!result) {
return null;
}
return result.codeResult.code;
}
}
interface ScanProps {
onDetected: (barcode: string) => void;
handleSubmit: (barcode: string, obj: object) => void;
}
interface ScanState {
scanning: boolean;
results: any[];
codeResult: string;
}
class Scan extends React.Component<ScanProps, ScanState> {
state: ScanState = {
scanning: false,
results: [],
codeResult: "",
};
_scan = () => {
this.setState({ scanning: true });
};
_onDetected = (result: any) => {
const { onDetected, handleSubmit } = this.props;
const { results } = this.state;
const newResults = [...results, result];
const codeResult = result.codeResult.code;
this.setState({ results: newResults, codeResult, scanning: false });
onDetected(codeResult);
Quagga.stop();
handleSubmit(codeResult, {});
};
render() {
const { scanning } = this.state;
return (
<>
<button
type="button"
aria-label="Barcode scannen"
role="button"
tabIndex={0}
onClick={this._scan}
>
<span className="icon-barcode" />
</button>
{scanning ? (
<Scanner
onDetected={this._onDetected}
setScanning={(scanning) => this.setState({ scanning })}
/>
) : null}
</>
);
}
}
export default Scan;

View file

@ -1,507 +0,0 @@
"use client";
import Veganify from "@frontendnetwork/veganify";
import Image from "next/image";
import { useTranslations } from "next-intl";
import React, { useState, useEffect, useRef, FormEvent } from "react";
import ModalWrapper from "@/components/elements/modalwrapper";
import ShareButton from "@/components/elements/share";
import { ErrorResponse } from "@/models/ErrorRepsonse";
import { ProductResult } from "@/models/ProductResults";
import { Sources } from "@/models/Sources";
import Scan from "./Scanner/scanner";
const ProductSearch = () => {
const t = useTranslations("Check");
const formRef = useRef<HTMLFormElement>(null);
const [result, setResult] = useState<ProductResult>({
productname: "",
vegan: "n/a",
vegetarian: "n/a",
animaltestfree: "n/a",
palmoil: "n/a",
nutriscore: "",
grade: "",
});
const [sources, setSources] = useState<Sources>({});
const [barcode, setBarcode] = useState<string>("");
const [showFound, setShowFound] = useState<boolean>(false);
const [showNotFound, setShowNotFound] = useState<boolean>(false);
const [showInvalid, setShowInvalid] = useState<boolean>(false);
const [showTimeout, setShowTimeout] = useState<boolean>(false);
const [showTimeoutFinal, setShowTimeoutFinal] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
/* EAN from URL for iOS Shortcut */
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const eanFromURL = params.get("ean");
if (eanFromURL) {
setBarcode(eanFromURL);
handleSubmit(eanFromURL);
}
}, []);
/* Submitting */
const handleSubmit = async (barcode: string, event?: FormEvent) => {
event?.preventDefault();
setShowTimeoutFinal(false);
setShowTimeout(false);
setShowFound(false);
setShowNotFound(false);
setShowInvalid(false);
setLoading(true);
try {
const data = await Veganify.getProductByBarcode(
barcode,
process.env.NEXT_PUBLIC_STAGING === "true"
);
setLoading(false);
if (data.status === 200) {
if ("product" in data) {
setResult(data.product);
}
if ("sources" in data) {
setSources(data.sources);
}
setShowFound(true);
setShowTimeout(false);
} else if (data.status === 404) {
setShowNotFound(true);
setShowTimeout(false);
} else {
setShowInvalid(true);
setShowTimeout(false);
}
} catch (error) {
if (
typeof error === "object" &&
error !== null &&
"response" in error &&
"status" in (error as ErrorResponse).response
) {
if ((error as ErrorResponse).response.status == 400) {
setShowInvalid(true);
setShowTimeout(false);
} else if ((error as ErrorResponse).response.status == 404) {
setShowNotFound(true);
setShowTimeout(false);
}
} else {
console.error(error);
setShowTimeoutFinal(true);
setShowTimeout(false);
}
setLoading(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setBarcode(e.target.value);
};
const productname = result.productname === "n/a" ? "?" : result.productname;
let vegan = "unknown icon-help";
if (result.vegan === true) {
vegan = "vegan icon-ok";
} else if (result.vegan === false) {
vegan = "non-vegan icon-cancel";
}
let vegetarian = "unknown icon-help";
if (result.vegetarian === true) {
vegetarian = "vegan icon-ok";
} else if (result.vegetarian === false) {
vegetarian = "non-vegan icon-cancel";
}
let animaltestfree = "unknown icon-help";
if (result.animaltestfree === true) {
animaltestfree = "vegan icon-ok";
} else if (result.animaltestfree === false) {
animaltestfree = "non-vegan icon-cancel";
}
let palmoil = "unknown icon-help";
if (result.palmoil === true) {
palmoil = "non-vegan icon-cancel";
} else if (result.palmoil === false) {
palmoil = "vegan icon-ok";
}
let nutriscore = result.nutriscore;
let grade = result.grade;
const api = sources.api;
const uri = sources.baseuri;
if (nutriscore === "n/a") {
nutriscore = "unknown icon-help";
} else if (nutriscore === "a" || nutriscore === "A") {
nutriscore = "nutri_a icon-a";
} else if (nutriscore === "b" || nutriscore === "B") {
nutriscore = "nutri_b icon-b";
} else if (nutriscore === "c" || nutriscore === "C") {
nutriscore = "nutri_c icon-c";
} else if (nutriscore === "d" || nutriscore === "D") {
nutriscore = "nutri_d icon-d";
} else if (nutriscore === "e" || nutriscore === "E") {
nutriscore = "nutri_e icon-e";
}
if (grade === "n/a") {
grade = "unknown icon-help";
} else if (grade === "A") {
grade = "nutri_a icon-a";
} else if (grade === "B") {
grade = "nutri_b icon-b";
} else if (grade === "C") {
grade = "nutri_c icon-c";
} else if (grade === "D") {
grade = "nutri_d icon-d";
} else if (grade === "A+") {
grade = "nutri_a icon-a";
} else if (grade === "Not eligible") {
grade = "non-vegan icon-cancel";
}
return (
<>
<Image
src="/./img/Veganify.svg"
alt="Logo"
className={`logo ${loading ? "spinner" : ""}`}
width={48}
height={48}
/>
<form
ref={formRef}
id="eanform"
onSubmit={(e) => handleSubmit(barcode, e)}
>
<legend>{t("enterbarcode")}</legend>
<fieldset>
<legend>{t("enterbarcode")}</legend>
<Scan
onDetected={(barcode) => setBarcode(barcode)}
handleSubmit={(barcode) => handleSubmit(barcode)}
/>
<label htmlFor="barcodeInput" className="hidden">
{t("enterbarcode")}
</label>
<input
type="number"
name="barcode"
id="barcodeInput"
placeholder={t("enterbarcode")}
autoFocus={true}
value={barcode}
onChange={handleChange}
/>
<button name="submit" aria-label={t("submit")} role="button">
<span className="icon-right-open" />
</button>
</fieldset>
</form>
{showFound && (
<>
<div id="result">
<div className="resultborder" id="RSFound">
<span className="unknown">
<span className="name" id="name_sh">
{productname}
</span>
</span>
<span id="result_sh">
<div className="Grid">
<div className="Grid-cell description">{t("vegan")}</div>
<div className="Grid-cell icons RSVegan">
<span className={vegan}></span>
</div>
</div>
</span>
<div className="Grid">
<div className="Grid-cell description">{t("vegetarian")}</div>
<div className="Grid-cell icons RSVegetarian">
<span className={vegetarian}></span>
</div>
</div>
<div className="Grid">
<div className="Grid-cell description">
{t("palmoil")}
<ModalWrapper
id="palmoil"
buttonType="sup"
buttonClass="help-icon"
buttonText="?"
>
<span className="center">
<Image
src="../img/palmoil_img.svg"
className="heading_img"
alt="Palmoil"
width={48}
height={48}
/>
<h1>{t("palmoil")}</h1>
</span>
<p>{t("palmoil_desc")}</p>
</ModalWrapper>
</div>
<div className="Grid-cell icons RSPalmoil">
<span className={palmoil}></span>
</div>
</div>
<div
className="Grid Crueltyfree"
style={
animaltestfree === "unknown icon-help"
? { display: "none" }
: {}
}
>
<div className="Grid-cell description">{t("crueltyfree")}</div>
<div className="Grid-cell icons RSAnimaltestfree">
<span className={animaltestfree}></span>
</div>
</div>
<div className="Grid">
<div className="Grid-cell description">
Nutriscore
<ModalWrapper
id="nutriscore"
buttonType="sup"
buttonClass="help-icon"
buttonText="?"
>
<span className="center">
<Image
src="../img/nutriscore_image.svg"
className="heading_img"
alt="Nutriscore"
width={48}
height={48}
/>
<h1>Nutriscore</h1>
</span>
<p
dangerouslySetInnerHTML={{
__html: t("nutriscore_desc", {
Algorithmwatch:
'<a href="https://algorithmwatch.org/en/nutriscore/">Algorithmwatch</a>',
}),
}}
/>
</ModalWrapper>
</div>
<div className="Grid-cell icons RSNutriscore">
<span className={nutriscore}></span>
</div>
</div>
<div className="Grid">
<div className="Grid-cell description">
Grade
<ModalWrapper
id="grade"
buttonType="sup"
buttonClass="help-icon"
buttonText="?"
>
<span className="center">
<Image
src="../img/grade_img.svg"
className="heading_img"
alt="Grades"
width={48}
height={48}
/>
<h1>Grades</h1>
</span>
<p
dangerouslySetInnerHTML={{
__html: t("grades_desc", {
Grades:
'<a href="https://grade.veganify.app">Veganify Grades</a>',
}),
}}
/>
<span className="center">
<a href="https://grade.veganify.app" className="button">
Veganify Grades
</a>
</span>
</ModalWrapper>
</div>
<div className="Grid-cell icons RSGrade">
<span className={grade}></span>
</div>
</div>
<span className="source">
{t("source")}:{" "}
<a href={uri} className="RSSource" target="_blank">
{api}
</a>
<ModalWrapper
id="license"
buttonType="sup"
buttonClass="help-icon"
buttonText="?"
>
<span className="center">
<Image
src="../img/license_img.svg"
className="heading_img"
alt="Licenses"
width={48}
height={48}
/>
<h1>{t("licenses")}</h1>
</span>
<p>{t("licenses_desc")}</p>
<p>
&copy; OpenFoodFacts Contributors, licensed under{" "}
<a href="https://opendatacommons.org/licenses/odbl/1.0/">
Open Database License
</a>{" "}
and{" "}
<a href="https://opendatacommons.org/licenses/dbcl/1.0/">
Database Contents License
</a>
.<br />
&copy; Open EAN/GTIN Database Contributors, licensed under{" "}
<a href="https://www.gnu.org/licenses/fdl-1.3.html">
GNU FDL
</a>
.<br />
&copy; Veganify Contributors and Hamed Montazeri, licensed
under{" "}
<a href="https://github.com/JokeNetwork/vegan-ingredients-api/blob/master/LICENSE">
MIT License
</a>
, sourced from{" "}
<a href="https://www.veganpeace.com/ingredients/ingredients.htm">
VeganPeace
</a>
,{" "}
<a href="https://www.peta.org/living/food/animal-ingredients-list/">
PETA
</a>{" "}
and{" "}
<a href="https://www.veganwolf.com/animal_ingredients.htm">
The VEGAN WOLF
</a>
.<br />
&copy; Veganify Contributors, sourced from ©{" "}
<a href="https://crueltyfree.peta.org">
PETA (Beauty without Bunnies)
</a>
.
</p>
</ModalWrapper>
</span>
<ShareButton productName={productname} barcode={barcode} />
</div>
</div>
</>
)}
{showNotFound && (
<div id="result">
<div className="resultborder" id="RSNotFound">
<span>{t("notindb")}</span>
<p className="missing" style={{ textAlign: "center" }}>
{t("notindb_add")}{" "}
<a
href="https://world.openfoodfacts.org/cgi/product.pl"
target="_blank"
>
{t("add_food")}
</a>{" "}
{t("or")}{" "}
<a
href="https://world.openbeautyfacts.org/cgi/product.pl"
target="_blank"
>
{t("add_cosmetic")}
</a>
.
</p>
</div>
</div>
)}
{showInvalid && (
<div id="result">
<div className="resultborder" id="RSInvalid">
<span>{t("wrongbarcode")}</span>
</div>
</div>
)}
{showTimeout && (
<div className="timeout animated fadeIn">
{t("timeout1")}
<span>.</span>
<span>.</span>
<span>.</span>
</div>
)}
{showTimeoutFinal && (
<div className="timeout-final animated fadeIn">{t("timeout2")}</div>
)}
{loading && (
<>
<div id="result" className="loading_skeleton">
<div className="animated fadeIn resultborder" id="RSFound">
<span className="unknown">
<span className="name skeleton">&nbsp;</span>
</span>
<span id="result_sh">
<div className="Grid">
<div className="Grid-cell description skeleton">
{t("vegan")}
</div>
<div className="Grid-cell icons skeleton">
<span className="icon-help"></span>
</div>
</div>
</span>
<div className="Grid">
<div className="Grid-cell description skeleton">
{t("vegetarian")}
</div>
<div className="Grid-cell icons skeleton">
<span className="icon-help"></span>
</div>
</div>
<div className="Grid">
<div className="Grid-cell description skeleton">
{t("palmoil")}
</div>
<div className="Grid-cell icons skeleton">
<span className="icon-help"></span>
</div>
</div>
<div className="Grid">
<div className="Grid-cell description skeleton">Nutriscore</div>
<div className="Grid-cell icons skeleton">
<span className="icon-help"></span>
</div>
</div>
<div className="Grid">
<div className="Grid-cell description skeleton">Grade</div>
<div className="Grid-cell icons skeleton">
<span className="icon-help"></span>
</div>
</div>
<span className="source skeleton">&nbsp;</span>
<span className="button skeleton">{t("share")}</span>
</div>
</div>
</>
)}
</>
);
};
export default ProductSearch;

View file

@ -1,8 +1,10 @@
"use client";
import React, {
useState,
useEffect,
useCallback,
useMemo,
useRef,
ReactNode,
} from "react";
import { createPortal } from "react-dom";
@ -23,11 +25,13 @@ const ModalWrapper = ({
buttonText,
}: ModalProps) => {
const [isOpen, setIsOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const modalRootRef = useRef<HTMLElement | null>(null);
const modalRoot = useMemo(() => {
return typeof document !== "undefined"
? document.getElementById("modal-root")
: null;
useEffect(() => {
setMounted(true);
modalRootRef.current = document.getElementById("modal-root");
return () => setMounted(false);
}, []);
const closeModal = useCallback(() => {
@ -41,6 +45,8 @@ const ModalWrapper = ({
}, []);
useEffect(() => {
if (!isOpen) return;
const handleEscapeKeyPress = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeModal();
@ -68,10 +74,12 @@ const ModalWrapper = ({
document.removeEventListener("keydown", handleEscapeKeyPress);
document.removeEventListener("touchstart", handleTouchStart);
};
}, [closeModal]);
}, [isOpen, closeModal]);
const ButtonComponent = buttonType;
if (!mounted) return null;
return (
<>
<ButtonComponent
@ -83,7 +91,7 @@ const ModalWrapper = ({
{buttonText}
</ButtonComponent>
{isOpen &&
modalRoot &&
modalRootRef.current &&
createPortal(
<div className="modal_view animated fadeInUp open">
<div className="modal_close">
@ -97,7 +105,7 @@ const ModalWrapper = ({
</div>
{children}
</div>,
modalRoot
modalRootRef.current
)}
</>
);

View file

@ -1,44 +1,51 @@
/* eslint-disable @next/next/no-img-element */
import Image from "next/image";
import { useTranslations } from "next-intl";
const FooterLink = ({
href,
src,
alt,
className = "labels",
width = 48,
height = 48,
}: {
interface FooterLinkProps {
href: string;
src: string;
alt: string;
className?: string;
width?: number;
height?: number;
}) => (
<a href={href} target="_blank" rel="noopener noreferrer">
<Image
src={src}
alt={alt}
className={className}
width={width}
height={height}
/>
</a>
);
}
function FooterLink({
href,
src,
alt,
className = "labels",
width = 48,
height = 48,
}: FooterLinkProps) {
return (
<a href={href} target="_blank" rel="noopener noreferrer">
<Image
src={src}
alt={alt}
className={className}
width={width}
height={height}
/>
</a>
);
}
interface CreditTextParams {
heart: string;
philipLink: string;
jokeLink: string;
}
export default function Footer() {
const t = useTranslations("Footer");
const isJanuary = new Date().getMonth() === 0;
const renderCreditText = () => ({
__html: t("credit", {
heart: '<i class="icon-heart"></i>',
philipLink: '<a href="https://philipbrembeck.com">Philip Brembeck</a>',
jokeLink: '<a href="https://frontendnet.work">FrontEndNet.work</a>',
}),
});
const creditText = t("credit", {
heart: '<i class="icon-heart"></i>',
philipLink: '<a href="https://philipbrembeck.com">Philip Brembeck</a>',
jokeLink: '<a href="https://frontendnet.work">FrontEndNet.work</a>',
} satisfies CreditTextParams);
return (
<footer>
@ -47,28 +54,34 @@ export default function Footer() {
target="_blank"
rel="noopener noreferrer"
>
<img
src="https://api.producthunt.com/widgets/embed-image/v1/top-post-topic-badge.svg?post_id=396704&theme=neutral&period=weekly&topic_id=43"
<Image
src="../img/ph_neutral.svg"
alt="Veganify | Product Hunt"
height="30"
width={132}
height={40}
/>
</a>
<p dangerouslySetInnerHTML={renderCreditText()} />
<p dangerouslySetInnerHTML={{ __html: creditText }} />
<FooterLink
href={isJanuary ? "https://vegc.net/veganuary" : "https://veganify.app"}
src={isJanuary ? "../img/veganuary.svg" : "../img/veganify_text.svg"}
alt={isJanuary ? "Go to Veganuary" : "Veganify Logo"}
/>
<FooterLink
href="https://github.com/frontendnetwork/veganify"
src="../img/opensource.svg"
alt="Open Source"
/>
<FooterLink
href="https://www.thegreenwebfoundation.org/green-web-check/?url=https%3A%2F%2Fveganify.app"
src="../img/greenhosted.svg"
alt="Hosted Green"
/>
<FooterLink
href="https://iplantatree.org/user/Veganify"
src="../img/treelabel.svg"

View file

@ -1,242 +0,0 @@
"use client";
import Veganify from "@frontendnetwork/veganify";
import Image from "next/image";
import { useTranslations } from "next-intl";
import React, { useState, useCallback } from "react";
import ModalWrapper from "@/components/elements/modalwrapper";
interface IngredientResult {
vegan: boolean | null;
surelyVegan: string[];
notVegan: string[];
maybeVegan: string[];
}
const IngredientsCheck: React.FC = () => {
const t = useTranslations("Ingredients");
const [result, setResult] = useState<IngredientResult>({
vegan: null,
surelyVegan: [],
notVegan: [],
maybeVegan: [],
});
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleSubmit = useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setResult({ vegan: null, surelyVegan: [], notVegan: [], maybeVegan: [] });
setError(null);
const formData = new FormData(event.currentTarget);
const ingredients = formData.get("ingredients") as string;
if (!ingredients.trim()) {
setError(t("cannotbeempty"));
return;
}
setLoading(true);
try {
const data = await Veganify.checkIngredientsList(
ingredients,
process.env.NEXT_PUBLIC_STAGING === "true"
);
setResult({
vegan: data.data.vegan,
surelyVegan: data.data.surely_vegan,
notVegan: data.data.not_vegan,
maybeVegan: data.data.maybe_vegan,
});
} catch (error) {
setError(t("cannotbeempty"));
} finally {
setLoading(false);
}
},
[t]
);
const renderIngredientList = (items: string[], iconClass: string) => (
<>
{items.map((item) => (
<div className="Grid" key={item}>
<div className="Grid-cell description">
{item.charAt(0).toUpperCase() + item.slice(1)}
</div>
<div className="Grid-cell icons">
<span className={iconClass}></span>
</div>
</div>
))}
</>
);
return (
<>
<Image
src="/./img/Veganify.svg"
alt="Logo"
className={`logo ${loading ? "spinner" : ""}`}
width={48}
height={48}
/>
<h2 style={{ textAlign: "center", marginTop: "0" }}>
{t("ingredientcheck")}
</h2>
<p style={{ textAlign: "center" }}>{t("ingredientcheck_desc")}</p>
<form onSubmit={handleSubmit}>
<fieldset>
<legend>{t("entercommaseperated")}</legend>
<textarea
id="ingredients"
name="ingredients"
placeholder={t("entercommaseperated")}
/>
<button
type="submit"
name="checkingredients"
aria-label={t("submit")}
>
<span className="icon-right-open"></span>
</button>
</fieldset>
</form>
{result.vegan !== null && (
<div id="result">
<div className="">
<div className="resultborder">
<div className="Grid">
<div className="Grid-cell description">
<b>{t("vegan")}</b>
</div>
<div className="Grid-cell icons">
<span
className={
result.vegan ? "vegan icon-ok" : "non-vegan icon-cancel"
}
></span>
</div>
</div>
{renderIngredientList(result.notVegan, "non-vegan icon-cancel")}
{renderIngredientList(result.maybeVegan, "maybe-vegan icon-help")}
{renderIngredientList(result.surelyVegan, "vegan icon-ok")}
<span className="source">
{t("source")}:{" "}
<a href="https://www.veganpeace.com/ingredients/ingredients.htm">
VeganPeace
</a>{" "}
,{" "}
<a href="https://www.peta.org/living/food/animal-ingredients-list/">
PETA
</a>{" "}
&amp;{" "}
<a href="https://www.veganwolf.com/animal_ingredients.htm">
The VEGAN WOLF
</a>
<ModalWrapper
id="license"
buttonType="sup"
buttonClass="help-icon"
buttonText="?"
>
<span className="center">
<Image
src="../img/license_img.svg"
className="heading_img"
alt="Licenses"
width={48}
height={48}
/>
<h1>{t("licenses")}</h1>
</span>
<p>{t("licenses_desc")}</p>
<p>
&copy; OpenFoodFacts Contributors, licensed under{" "}
<a href="https://opendatacommons.org/licenses/odbl/1.0/">
Open Database License
</a>{" "}
and{" "}
<a href="https://opendatacommons.org/licenses/dbcl/1.0/">
Database Contents License
</a>
.<br />
&copy; Open EAN/GTIN Database Contributors, licensed under{" "}
<a href="https://www.gnu.org/licenses/fdl-1.3.html">
GNU FDL
</a>
.<br />
&copy; Veganify Contributors and Hamed Montazeri, licensed
under{" "}
<a href="https://github.com/JokeNetwork/vegan-ingredients-api/blob/master/LICENSE">
MIT License
</a>
, sourced from{" "}
<a href="https://www.veganpeace.com/ingredients/ingredients.htm">
VeganPeace
</a>
,{" "}
<a href="https://www.peta.org/living/food/animal-ingredients-list/">
PETA
</a>{" "}
and{" "}
<a href="https://www.veganwolf.com/animal_ingredients.htm">
The VEGAN WOLF
</a>
.<br />
&copy; Veganify Contributors, sourced from ©{" "}
<a href="https://crueltyfree.peta.org">
PETA (Beauty without Bunnies)
</a>
.
</p>
</ModalWrapper>
<br />
<span
dangerouslySetInnerHTML={{
__html: t("languagewarning", {
deepl: '<a href="https://deepl.com">DeepL</a>',
}),
}}
/>
</span>
</div>
</div>
</div>
)}
{error && (
<div id="result">
<span className="animated fadeIn">
<div className="resultborder">{error}</div>
</span>
</div>
)}
{loading && (
<div id="result" className="loading_skeleton">
<div className="animated fadeIn">
<div className="resultborder">
<div className="Grid">
<div className="Grid-cell description skeleton">
<b>{t("vegan")}</b>
</div>
<div className="Grid-cell icons skeleton">
<span className="icon-help"></span>
</div>
</div>
<span className="source skeleton">&nbsp;</span>
<span className="source skeleton">&nbsp;</span>
<span className="source skeleton">&nbsp;</span>
</div>
</div>
</div>
)}
</>
);
};
export default IngredientsCheck;

View file

@ -183,6 +183,7 @@ sup {
height: auto;
background-color: var(--modal-bg-overlay);
backdrop-filter: blur(rem(3.2px));
@include mq(desktop) {
position: initial;
}
@ -200,12 +201,15 @@ sup {
position: absolute;
right: rem(32px);
transform: scale(1.2);
@include mq(s-desktop) {
transform: scale(1.5);
}
@include mq(s-phone) {
transform: scale(1.2);
}
@include mq(phone) {
transform: scale(1.5);
}
@ -233,11 +237,13 @@ sup {
color: var(--modal-fg) !important;
}
}
.twitter {
border-top-right-radius: rem(8px);
border-top-left-radius: rem(8px);
}
.facebook {
.last {
border-bottom-right-radius: rem(8px);
border-bottom-left-radius: rem(8px);
border-bottom: none;
@ -263,4 +269,4 @@ sup {
top: 0;
padding: rem(16px);
text-decoration: none;
}
}