mirror of
https://github.com/frontendnetwork/vegancheck.me
synced 2024-11-13 23:57:09 +00:00
feat: Major refactor for performance improvements
This commit is contained in:
parent
ebf2f4e714
commit
1b3bc1c9ba
37 changed files with 1383 additions and 1227 deletions
1
.vscode/settings.json
vendored
Normal file
1
.vscode/settings.json
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
{}
|
|
@ -16,11 +16,7 @@ let nextConfig = {
|
|||
{
|
||||
source: "/datenschutz",
|
||||
destination: "/privacy-policy",
|
||||
},
|
||||
{
|
||||
source: "/impressum",
|
||||
destination: "/en/impressum",
|
||||
},
|
||||
}
|
||||
];
|
||||
},
|
||||
};
|
||||
|
|
18
public/img/ph_dark.svg
Normal file
18
public/img/ph_dark.svg
Normal 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 & 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
18
public/img/ph_light.svg
Normal 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 & 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
18
public/img/ph_neutral.svg
Normal 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 & 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 |
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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") }} />
|
||||
|
|
53
src/components/Check/LoadingSkeleton.tsx
Normal file
53
src/components/Check/LoadingSkeleton.tsx
Normal 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"> </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"> </span>
|
||||
<span className="button skeleton">{t("share")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
211
src/components/Check/ProductResult.tsx
Normal file
211
src/components/Check/ProductResult.tsx
Normal 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>
|
||||
© 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 />
|
||||
© Open EAN/GTIN Database Contributors, licensed under{" "}
|
||||
<a href="https://www.gnu.org/licenses/fdl-1.3.html">GNU FDL</a>
|
||||
.<br />
|
||||
© 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 />
|
||||
© 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>
|
||||
);
|
||||
}
|
21
src/components/Check/ResultGrid.tsx
Normal file
21
src/components/Check/ResultGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
59
src/components/Check/SearchForm.tsx
Normal file
59
src/components/Check/SearchForm.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
73
src/components/Check/StatusMessages.tsx
Normal file
73
src/components/Check/StatusMessages.tsx
Normal 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;
|
||||
}
|
114
src/components/Check/index.tsx
Normal file
114
src/components/Check/index.tsx
Normal 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 />}
|
||||
</>
|
||||
);
|
||||
}
|
13
src/components/Check/models/product.ts
Normal file
13
src/components/Check/models/product.ts
Normal 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;
|
||||
}
|
34
src/components/Check/utils/product-actions.ts
Normal file
34
src/components/Check/utils/product-actions.ts
Normal 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;
|
||||
}
|
||||
}
|
34
src/components/Check/utils/product-helpers.ts
Normal file
34
src/components/Check/utils/product-helpers.ts
Normal 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),
|
||||
};
|
||||
}
|
109
src/components/IngredientsCheck/IngredientsForm.tsx
Normal file
109
src/components/IngredientsCheck/IngredientsForm.tsx
Normal 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"> </span>
|
||||
<span className="source skeleton"> </span>
|
||||
<span className="source skeleton"> </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
24
src/components/IngredientsCheck/IngredientsList.tsx
Normal file
24
src/components/IngredientsCheck/IngredientsList.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
45
src/components/IngredientsCheck/ResultsDisplay.tsx
Normal file
45
src/components/IngredientsCheck/ResultsDisplay.tsx
Normal 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>
|
||||
);
|
||||
}
|
55
src/components/IngredientsCheck/SourceInfo.tsx
Normal file
55
src/components/IngredientsCheck/SourceInfo.tsx
Normal 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>{" "}
|
||||
&{" "}
|
||||
<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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
export interface IngredientResult {
|
||||
vegan: boolean | null;
|
||||
surelyVegan: string[];
|
||||
notVegan: string[];
|
||||
maybeVegan: string[];
|
||||
}
|
26
src/components/IngredientsCheck/utils/actions.ts
Normal file
26
src/components/IngredientsCheck/utils/actions.ts
Normal 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");
|
||||
}
|
||||
}
|
41
src/components/Scanner/ScanButton.tsx
Normal file
41
src/components/Scanner/ScanButton.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
129
src/components/Scanner/ViewportScanner.tsx
Normal file
129
src/components/Scanner/ViewportScanner.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
1
src/components/Scanner/index.ts
Normal file
1
src/components/Scanner/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { ScanButton as default } from "./ScanButton";
|
10
src/components/Scanner/models/scanner.ts
Normal file
10
src/components/Scanner/models/scanner.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
export interface ScannerProps {
|
||||
onDetected: (result: DetectionResult) => void;
|
||||
setScanning: (scanning: boolean) => void;
|
||||
}
|
||||
|
||||
export interface DetectionResult {
|
||||
codeResult: {
|
||||
code: string;
|
||||
};
|
||||
}
|
|
@ -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;
|
|
@ -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>
|
||||
© 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 />
|
||||
© Open EAN/GTIN Database Contributors, licensed under{" "}
|
||||
<a href="https://www.gnu.org/licenses/fdl-1.3.html">
|
||||
GNU FDL
|
||||
</a>
|
||||
.<br />
|
||||
© 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 />
|
||||
© 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"> </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"> </span>
|
||||
<span className="button skeleton">{t("share")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductSearch;
|
|
@ -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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>{" "}
|
||||
&{" "}
|
||||
<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>
|
||||
© 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 />
|
||||
© Open EAN/GTIN Database Contributors, licensed under{" "}
|
||||
<a href="https://www.gnu.org/licenses/fdl-1.3.html">
|
||||
GNU FDL
|
||||
</a>
|
||||
.<br />
|
||||
© 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 />
|
||||
© 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"> </span>
|
||||
<span className="source skeleton"> </span>
|
||||
<span className="source skeleton"> </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default IngredientsCheck;
|
|
@ -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;
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue