* build(deps-dev): bump @types/react from 18.3.3 to 18.3.11

Bumps [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) from 18.3.3 to 18.3.11.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

---
updated-dependencies:
- dependency-name: "@types/react"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(deps-dev): bump eslint-config-next from 14.2.5 to 14.2.14

Bumps [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) from 14.2.5 to 14.2.14.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v14.2.14/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: eslint-config-next
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(deps): bump next-intl from 3.17.2 to 3.20.0

Bumps [next-intl](https://github.com/amannn/next-intl) from 3.17.2 to 3.20.0.
- [Release notes](https://github.com/amannn/next-intl/releases)
- [Changelog](https://github.com/amannn/next-intl/blob/main/CHANGELOG.md)
- [Commits](https://github.com/amannn/next-intl/compare/v3.17.2...v3.20.0)

---
updated-dependencies:
- dependency-name: next-intl
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(deps-dev): bump @playwright/test from 1.46.1 to 1.47.2

Bumps [@playwright/test](https://github.com/microsoft/playwright) from 1.46.1 to 1.47.2.
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.46.1...v1.47.2)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(deps-dev): bump eslint-config-next from 14.2.14 to 14.2.15

Bumps [eslint-config-next](https://github.com/vercel/next.js/tree/HEAD/packages/eslint-config-next) from 14.2.14 to 14.2.15.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v14.2.15/packages/eslint-config-next)

---
updated-dependencies:
- dependency-name: eslint-config-next
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(deps): bump typescript from 5.5.4 to 5.6.3

Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.5.4 to 5.6.3.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.5.4...v5.6.3)

---
updated-dependencies:
- dependency-name: typescript
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(deps): bump next from 14.2.14 to 14.2.15

Bumps [next](https://github.com/vercel/next.js) from 14.2.14 to 14.2.15.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v14.2.14...v14.2.15)

---
updated-dependencies:
- dependency-name: next
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(deps-dev): bump @playwright/test from 1.47.2 to 1.48.0

Bumps [@playwright/test](https://github.com/microsoft/playwright) from 1.47.2 to 1.48.0.
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.47.2...v1.48.0)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* feat: Init Change to UseAppRouter

* feat: Change Package manager to pnpm

* fix: Use pnpm action

* ci: Cache dependencies

* fix: Remove package-lock.json

* build(deps): bump next-intl from 3.20.0 to 3.21.1

Bumps [next-intl](https://github.com/amannn/next-intl) from 3.20.0 to 3.21.1.
- [Release notes](https://github.com/amannn/next-intl/releases)
- [Changelog](https://github.com/amannn/next-intl/blob/main/CHANGELOG.md)
- [Commits](https://github.com/amannn/next-intl/compare/v3.20.0...v3.21.1)

---
updated-dependencies:
- dependency-name: next-intl
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(deps): bump pnpm/action-setup from 2 to 4

Bumps [pnpm/action-setup](https://github.com/pnpm/action-setup) from 2 to 4.
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v2...v4)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* feat: Refactor code

* fix: Memoize shareOptions

* feat: Upgrade to Next.js 15 / React 19

* docs: Remove seperate documents as they're not needed anymore

Will be included later on in the README

* chore: Use Interface on memo

* fix: Use of math in sass files

* feat: Major refactor for performance improvements

* fix: Minor issues (CC: Sonar)

* fix: Remove type any

* fix: Union type overwrite

* feat: Upgrade to next.config.ts

* chore(deps): Upgrade ESLint v9

* ci: Update pnpm version

* feat: Improve React. imports as direct imports

* feat: Add preprocessor to ingredients form

* fix(a11y): Change color on source links for AAA contrast ratio

* fix: Linting error import/order

* test: Add unit tests for utils

* build(deps): bump sass from 1.79.4 to 1.80.4

Bumps [sass](https://github.com/sass/dart-sass) from 1.79.4 to 1.80.4.
- [Release notes](https://github.com/sass/dart-sass/releases)
- [Changelog](https://github.com/sass/dart-sass/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sass/dart-sass/compare/1.79.4...1.80.4)

---
updated-dependencies:
- dependency-name: sass
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(deps): bump @types/node from 22.5.4 to 22.8.1

Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.5.4 to 22.8.1.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* build(deps): bump @frontendnetwork/veganify from 1.2.5 to 1.2.9

Bumps [@frontendnetwork/veganify](https://github.com/JokeNetwork/veganify-api-wrapper) from 1.2.5 to 1.2.9.
- [Commits](https://github.com/JokeNetwork/veganify-api-wrapper/commits)

---
updated-dependencies:
- dependency-name: "@frontendnetwork/veganify"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>

* ci: Run e2e tests after deploying

* chore: Setup Playwright

* feat: Reuse License modal

* test: Change E2E to staging url

* fix: Product Hunt Badge size

* docs: Added improved dev guide

* docs: Formatting

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
Philip Jake 2024-10-27 00:29:14 +01:00 committed by GitHub
parent c166e98eeb
commit eb75ccc5eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
91 changed files with 12303 additions and 12367 deletions

View file

@ -1,14 +1,45 @@
{
"extends": ["next/core-web-vitals", "plugin:@typescript-eslint/recommended", "plugin:import/errors", "plugin:import/warnings", "plugin:import/typescript"],
"root": true,
"extends": [
"next/core-web-vitals",
"plugin:@typescript-eslint/strict",
"plugin:@typescript-eslint/stylistic",
"plugin:import/recommended",
"plugin:import/typescript"
],
"plugins": ["@typescript-eslint", "import"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"project": "./tsconfig.json",
"ecmaVersion": 2022,
"sourceType": "module"
},
"settings": {
"import/resolver": {
"typescript": {
"project": "./tsconfig.json"
},
"node": true
}
},
"rules": {
"import/order": ["error", {
"groups": ["builtin", "external", "internal", "parent", "sibling", "index"],
"newlines-between": "always",
"alphabetize": {
"order": "asc",
"caseInsensitive": true
"import/order": [
"error",
{
"groups": [
"builtin",
"external",
"internal",
"parent",
"sibling",
"index"
],
"newlines-between": "always",
"alphabetize": {
"order": "asc",
"caseInsensitive": true
}
}
}]
]
}
}

View file

@ -143,3 +143,35 @@ jobs:
- name: Restart Kubernetes Pod
run: |
kubectl rollout restart deployment/veganify -n veganify-staging
e2e-tests:
needs: deploy-staging
if: github.ref == 'refs/heads/staging'
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install pnpm
uses: pnpm/action-setup@v2
with:
version: 9.12.1
- name: Install dependencies
run: pnpm install
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Wait for deployment to be ready
run: |
# Wait for 30 seconds to allow deployment to stabilize
sleep 30
- name: Run Playwright tests
run: npx playwright test

View file

@ -1,36 +1,38 @@
name: Build and Lint PRs
on: [pull_request]
jobs:
regular_build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9.12.1
- uses: actions/setup-node@v4
with:
node-version: '18.x'
cache: 'npm'
- run: npm ci
- run: npm run build
- run: npm run lint
node-version: "20.x"
cache: "pnpm"
cache-dependency-path: "**/pnpm-lock.yaml"
- run: pnpm install
- run: pnpm run lint
- run: pnpm run type-check
- run: pnpm run test
- run: pnpm run build
legacy_peer_deps_build:
runs-on: ubuntu-latest
needs: regular_build
if: ${{ always() && needs.regular_build.result == 'failure' }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9.12.1
- uses: actions/setup-node@v4
with:
node-version: '18.x'
cache: 'npm'
- run: npm ci --legacy-peer-deps
- run: npm run build
- run: npm run lint
node-version: "20.x"
cache: "pnpm"
- run: pnpm install --no-strict-peer-dependencies
- run: pnpm run lint
- run: pnpm run type-check
- run: pnpm run test
- run: pnpm run build

1
.gitignore vendored
View file

@ -37,3 +37,4 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
test-results/.last-run.json

View file

@ -1,5 +1,7 @@
{
"recommendations": [
"inlang.vs-code-extension"
"inlang.vs-code-extension",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
]
}
}

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

@ -0,0 +1,22 @@
{
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
},
"editor.formatOnSave": true,
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"eslint.workingDirectories": [
{
"mode": "location"
}
],
"eslint.options": {
"overrideConfigFile": ".eslintrc.json"
},
"typescript.tsdk": "node_modules/typescript",
"typescript.enablePromptUseWorkspaceTsdk": true
}

View file

@ -1,4 +1,4 @@
FROM node:18-alpine AS base
FROM node:20-alpine AS base
# Install dependencies only when needed
FROM base AS deps

161
README.md
View file

@ -13,44 +13,170 @@ Check if a product is vegan or not with <a href="https://veganify.app"><strong>
</div>
## Overview
Veganify checks the barcode (EAN or UPC) of a food- or non-food-product and tells you if it is vegan or not. It is an useful tool for vegans and vegetarians - Developed with usability and simplicity in mind, so without distracting irrelevant facts or advertising.
Veganify combines the Databases of OpenFoodFacts, OpenBeautyFacts and Open EAN Database, as well as our very own ingredient checker in one tool.
Veganify combines the Databases of OpenFoodFacts, OpenBeautyFacts and Open EAN Database, as well as our very own ingredient checker in one tool.
<details>
<summary>See an example of how it works!</summary>
<img src="https://user-images.githubusercontent.com/4144601/198900839-8dc58d58-fdb8-48b6-93e4-a4662ae64954.mov" width="300">
<img src="https://user-images.githubusercontent.com/4144601/198900861-49ef1a5f-0663-4d73-b72d-d147cddaabd3.MP4" width="300">
</details>
</details>
The [Veganify Ingredients API](https://github.com/frontendnetwork/Veganify-API) checks the products ingredients against a list of thousands of non-vegan items.
<p align="center">
<a href="https://veganify.app">Open PWA in browser</a> | <a href="https://frontendnet.work/#projects">Product page on FrontEndNetwork</a> | <a href="https://frontendnet.work/veganify-api">Use the API</a> | <a href="https://shareshortcuts.com/shortcuts/2224-vegancheck.html">iOS Shortcut</a> | <a href="https://stats.uptimerobot.com/LY1gRuP5j6">Uptime Status</a>
</p>
## Installation
[Click here to see the installation guide!](https://frontendnetwork.github.io/veganify/)
## Developer Guide
## Contribute & Support
We're happy you want to help! Please read our [Code of Conduct](https://github.com/frontendnetwork/veganify/blob/main/CODE_OF_CONDUCT.md).
> [!TIP]
> We're using [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) for commit messages. Please follow this convention when making changes.
### Prerequisites
- Node.js 20 or later
- pnpm (enabled via corepack)
To enable pnpm using corepack:
```bash
corepack enable
corepack prepare pnpm@latest --activate
```
### Getting Started
1. Clone the repository:
```bash
git clone https://github.com/frontendnetwork/veganify.git
cd veganify
```
2. Install dependencies & start dev server:
```bash
pnpm install
pnpm dev
```
### Project Structure
```
src/
├── @components/
│ ├── shared/
│ ├── ComponentName/
│ │ ├── hooks/ # Component-specific hooks
│ │ ├── utils/ # Component-specific utilities
│ │ │ ├── util.ts
│ │ │ └── util.test.ts # Utility specify tests
│ │ ├── models/ # Component-specific types/interfaces
│ │ ├── componentPart.tsx # Component files
│ │ └── index.tsx # Component files
├── @models/ # Global type definitions
├── styles/ # CSS styles
├── tests/ # Only test setup files & Playwright tests
└── locales/ # next-intl translation files
```
### Development Commands
```bash
# Start development server
pnpm dev
# Run linting
pnpm lint
# Run type checking
pnpm check-types
# Run unit tests
pnpm test
# Run end-to-end tests
pnpm test:e2e
# Build for production
pnpm build
```
### Development Guidelines
> [!NOTE]
> We're aware not everything in this repo follows those standards. This is because of how the project was started and evolved. We're working on improving this.
#### Component Structure
- Break down components into smaller, reusable pieces
- Each significant component should have its own directory with the following structure:
- `hooks/` for component-specific hooks
- `utils/` for component-specific utilities
- `models/` for component-specific types
- Small, simple components can be single files
#### Testing
- All utility functions must have 100% test coverage
- Tests are written using Jest for unit testing
- Components currently don't require test coverage
- Playwright is used for end-to-end testing but currently only coversa few basics use cases. More tests are needed.
#### TypeScript
- TypeScript is mandatory
- The `any` type is not acceptable unless absolutely necessary
- Always define proper interfaces and types in the appropriate `models` folder
- Use type inference when possible
#### Internationalization
- Use `next-intl` for translations
- Add new translations to all language files in `/locales`
- Follow the existing translation key structure
#### Code Style
- Follow Node.js, React, and Next.js best practices
- Use the App Router pattern for routing
- Keep components pure and functional when possible
- Use hooks for state management and side effects
- Follow the DRY (Don't Repeat Yourself) principle
- Use meaningful variable and function names
- Write comments for complex logic
- Keep functions small and focused
#### Styling
- Place all styles in the `styles` folder
- Keep styles modular and scoped to components when possible
- Be sure to use SCSS for styling
- Use CSS variables for theming and repeated values
When making a contribution, please follow these guidelines to ensure consistency and maintainability.
Remember that every contribution, no matter how small, is valuable to the project. Thank you for helping make Veganify better!
## Support
Please refer to our issue trackers to see where you could help:
Please refer to our issue trackers to see where you could help:
- [[Tasks] Code Improvements](https://github.com/frontendnetwork/veganify/issues/52)
- [[Tasks] Localization](https://github.com/frontendnetwork/veganify/issues/59) - Learn how to localize Veganify [here](https://frontendnetwork.github.io/veganify/localization)
- [[Tasks] Localization](https://github.com/frontendnetwork/veganify/issues/59)
<a href="https://fink.inlang.com/github.com/frontendnetwork/veganify?ref=badge"><img src="https://badge.inlang.com/?url=github.com/frontendnetwork/veganify" alt="Veganify on Inlang" style="border-radius: 5%;"></a>
or if you find something else you could improve, just open a new issue for it!
### Support us
<a href="https://github.com/sponsors/philipbrembeck"><img src="https://img.shields.io/badge/Sponsor%20on%20GitHub-white.svg?logo=githubsponsors" alt="Consider Sponsoring"></a>
<a href="https://ko-fi.com/vegancheck"><img src="https://img.shields.io/badge/Buy%20us%20a%20coffee-white.svg?logo=kofi" alt="Buy us a coffee"></a>
<a href="https://www.paypal.com/donate/?hosted_button_id=J7TEA8GBPN536"><img src="https://shields.io/badge/Donate%20with%20PayPal-blue?style=flat&logo=Paypal" alt="Donate"></a>
### Premium Supporters
<a href="https://veganism.social/@mvtracing">
<picture>
<source srcset="https://user-images.githubusercontent.com/4144601/218593453-28333f8a-3e24-46d2-8bc9-856eb2e4a390.png" media="(prefers-color-scheme: dark)" width="120">
@ -65,13 +191,14 @@ or if you find something else you could improve, just open a new issue for it!
</picture>
</a>
## Dependencies & Credits
## Dependencies & Credits
This repo uses:
* [Quagga.js](https://serratus.github.io/quaggaJS/)
* [OpenFoodFacts API](https://openfoodfacts.org/) & [OpenBeautyFacts API](https://openbeautyfacts.org/) [@openfoodfacts](https://github.com/openfoodfacts)
* [Open EAN Database](https://opengtindb.org)
- [Quagga.js](https://serratus.github.io/quaggaJS/)
- [OpenFoodFacts API](https://openfoodfacts.org/) & [OpenBeautyFacts API](https://openbeautyfacts.org/) [@openfoodfacts](https://github.com/openfoodfacts)
- [Open EAN Database](https://opengtindb.org)
## License
All text and code in this repository is licensed under [MIT](https://github.com/frontendnetwork/veganify/blob/main/LICENSE), © 2023 Philip Brembeck, © 2023 FrontEndNetwork.
All text and code in this repository is licensed under [MIT](https://github.com/frontendnetwork/veganify/blob/main/LICENSE), © 2024 Philip Brembeck, © 2024 FrontEndNetwork.

View file

@ -1 +0,0 @@
[data-color-mode="dark"],[data-dark-theme="dark"]{--color:#f8f8f2;--shadow:0 1px rgba(0,0,0,.3)}[data-color-mode="light"],[data-dark-theme="light"]{--color:#000;--shadow:none}code[class*="language-"],pre[class*="language-"]{color:var(--color);background:0 0;text-shadow:var(--shadow);font-family:Consolas,Monaco,"Andale Mono","Ubuntu Mono",monospace;font-size:1em;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;line-height:1.5;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*="language-"]{padding:1em;margin:.5em 0;overflow:auto;border-radius:.3em}:not(pre) >code[class*="language-"],pre[class*="language-"]{background:#272822}:not(pre) >code[class*="language-"]{padding:.1em;border-radius:.3em;white-space:normal}.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#8292a2}.token.punctuation{color:#f8f8f2}.token.namespace{opacity:.7}.token.constant,.token.deleted,.token.property,.token.symbol,.token.tag{color:#f92672}.token.boolean,.token.number{color:#ae81ff}.token.attr-name,.token.builtin,.token.char,.token.inserted,.token.selector,.token.string{color:#a6e22e}.language-css .token.string,.style .token.string,.token.entity,.token.operator,.token.url,.token.variable{color:#f8f8f2}.token.atrule,.token.attr-value,.token.class-name,.token.function{color:#e6db74}.token.keyword{color:#66d9ef}.token.important,.token.regex{color:#fd971f}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}

File diff suppressed because one or more lines are too long

View file

@ -1,101 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-color-mode="dark" data-dark-theme="dark">
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="utf-8">
<title>Veganify Installation Guide</title>
<link rel="stylesheet" href="https://unpkg.com/@primer/css@^20.2.4/dist/primer.css">
<link rel="stylesheet" href="assets/prism.css">
<style>
body {
width: 50%;
}
@media only screen and (max-width: 750px) {
body {
width: 95%;
}
.logo_12 {
display: none;
}
}
</style>
</head>
<body class="m-5 mx-auto">
<div class="markdown-body">
<span class="branch-name">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" class="octicon octicon-git-branch">
<path fill-rule="evenodd" d="M11.75 2.5a.75.75 0 100 1.5.75.75 0 000-1.5zm-2.25.75a2.25 2.25 0 113 2.122V6A2.5 2.5 0 0110 8.5H6a1 1 0 00-1 1v1.128a2.251 2.251 0 11-1.5 0V5.372a2.25 2.25 0 111.5 0v1.836A2.492 2.492 0 016 7h4a1 1 0 001-1v-.628A2.25 2.25 0 019.5 3.25zM4.25 12a.75.75 0 100 1.5.75.75 0 000-1.5zM3.5 3.25a.75.75 0 111.5 0 .75.75 0 01-1.5 0z"></path>
</svg>
main
</span>
<span class="Label mr-1 Label--success">Stable</span>
<img width="80px" src="https://raw.githubusercontent.com/JokeNetwork/veganify.app/main/public/img/hero_icon.png" align="right" alt="Veganify Logo" class="logo_12">
<h1>veganify.app Installation Guide</h1>
<p>Installing veganify.app on your own server or hosting your own mirror is straight forward.<br>Here you can find out how it works and what to note.</p>
<h2 id="vercek">Deploy to Vercel</h2>
<p>The easiest way is to deploy the project to Vercel. Please note that Vercel is not GDPR compliant.</p>
<a href="https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FJokeNetwork%2Fveganify.app"><img src="https://vercel.com/button" alt="Deploy with Vercel"/></a>
<h2 id="requirements">Requirements</h2>
<ul>
<li>Node.js (React 18.2.0, Next.js 13.1.6) installed
<li><a href="https://docs.npmjs.com/cli/v8/commands/npm-install">npm</a> installed (is automatically installed with Node)</li>
<li>or: <a href="https://classic.yarnpkg.com/lang/en/docs/install/#mac-stable">Yarn</a> installed (<code>npm install --global yarn</code>)</li>
</ul>
<h2 id="get-started">Get started</h2>
<p>Download the <a href="https://github.com/JokeNetwork/veganify.app/releases">latest release</a> or just use the following command:</p>
<div class="highlight"><pre class="highlight"><code class="language-bash">$ git clone https://github.com/JokeNetwork/veganify.app.git</code></pre></div>
<h2 id="install-dependencies">Install dependencies</h2>
<p>Use the following commands to install all at once: </p>
<div class="highlight"><pre class="highlight"><code class="lang-bash">$ npm <span class="hljs-keyword">install</span></code></pre></div>
<p>or:</p>
<div class="highlight"><pre class="highlight"><code class="lang-bash">$ yarn <span class="hljs-keyword">install</span></code></pre></div>
<h2 id="start-the-service">Start the service</h2>
<p>In a local environment you can just run:</p>
<div class="highlight"><pre class="highlight"><code class="lang-bash">$ npm run start</span></code></pre></div>
<p>or:</p>
<div class="highlight"><pre class="highlight"><code class="lang-bash">$ yarn start</span></code></pre></div>
<p>To build the service just run:</p>
<div class="highlight"><pre class="highlight"><code class="lang-bash">$ npm run build</span></code></pre></div>
<p>or:</p>
<div class="highlight"><pre class="highlight"><code class="lang-bash">$ yarn build</span></code></pre></div>
<p>You can then run the code in a normal Node.js production environment (Like with the help of <a href="http://supervisord.org">supervisord</a>).</p>
<p>Please note that the service runs on <code>Port 1030</code>. You can change the port by editing the "start" command in the <a href="https://github.com/JokeNetwork/veganify.app/blob/main/package.json">package.json</a> at <code>"start": "next start -p [Desired port]"</code>.</p>
<h2 id="use-tests">Use Frontend Tests</h2>
<p>We use the Playwright Framework for our Frontend tests.<br />
For more information about Playwright, please visit <a href="https://playwright.dev">https://playwright.dev</a>.</p>
<p>To run the tests, just start the dev-environment:</p>
<div class="highlight"><pre class="highlight"><code class="lang-bash">$ npm run dev</code></pre></div>
<p>and then</p>
<div class="highlight"><pre class="highlight"><code class="lang-bash">$ npm run test</code></pre></div>
<p>All test should pass. Those test only check for the usabilty of the page. If you want to write your own tests, you can find the tests in <code>src/tests/usability.spec.ts</code>.</p>
<h2 id="use-of-icons">Use of icons</h2>
<ul>
<li>The veganify.app Logo and the app icons are free to use under the terms of the <a href="https://github.com/JokeNetwork/veganify.app/blob/main/LICENSE">MIT License</a>.</li>
<li>The Open-Source logo is free to use, as long as you publish your fork of the repository openly.</li>
<li>The Green Hosted logo is free to use under the terms of the <a href="https://github.com/JokeNetwork/veganify.app/blob/main/LICENSE">MIT License</a>, but only to be used if your fork is actually hosted green as of the criteria of <a href="https://www.thegreenwebfoundation.org">The Green Web Foundation</a>.</li>
<li>The We plant trees. logo is free to use under the terms of the <a href="https://github.com/JokeNetwork/veganify.app/blob/main/LICENSE">MIT License</a>, but only to be used if your fork of veganify.app plants trees in the tree planting team &quot;WE PLANT TREES.&quot; on <a href="https://iplantatree.org/user/veganify.app">I Plant A Tree</a>.</li>
<li>The icons used in our iconfonts are free to use under the terms of the <a href="https://github.com/JokeNetwork/veganify.app/blob/main/LICENSE">MIT License</a>.</li>
<div class="flash flash-warn p-3 mt-3 mb-3" role="alert">
<strong>Important!</strong> All other icons used in the veganify.app repo are exclusively licensed to veganify.app and the veganify.app domain by <a href="https://thenounproject.com">Noun Project</a>. If you want to use those icons, you will need to get a paid membership at Noun Project: <a href="https://thenounproject.com/pricing/">NounPro Unlimited</a>. Otherwise, please replace those icons.
</div>
</ul>
<h2 id="api-documentation">API documentation</h2>
<p>The API documentation was relocated and can now be found at one of the following locations:</p>
<ul>
<li><a href="https://jokenetwork.de/veganify-api">veganify.app API at JokeNetwork</a></li>
<li><a href="https://jokenetwork.de/veganify-ingredients-api">veganify.app Ingredients API at JokeNetwork</a></li>
<li><a href="https://jokenetwork.de/veganify-peta-api">veganify.app PETA API at JokeNetwork</a></li>
</ul>
<footer class="border-top">
<p class="color-fg-muted mt-2">&copy; 2024 <a href="//veganify.app">veganify.app</a> - Licensed under <a href="https://github.com/frontendnetwork/veganify.app/blob/main/LICENSE">MIT</a></p>
</footer>
</div>
<script src="assets/prism.js"></script>
</body>
</html>

View file

@ -1,186 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-color-mode="dark" data-dark-theme="dark">
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta charset="utf-8" />
<title>Veganify Localization Status</title>
<link
rel="stylesheet"
href="https://unpkg.com/@primer/css@^20.2.4/dist/primer.css"
/>
<link rel="stylesheet" href="assets/prism.css">
<style>
body {
width: 50%;
}
@media only screen and (max-width: 750px) {
body {
width: 95%;
}
img {
display: none;
}
}
.markdown-body table {
display: revert;
width: 100%;
max-width: 100%;
overflow: auto;
}
:not(pre) >code[class*="language-"], pre[class*="language-"] {
background: #000;
border: solid .01rem #ccc;
}
</style>
</head>
<body class="m-5 mx-auto">
<div class="markdown-body">
<span class="branch-name">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
width="16"
height="16"
class="octicon octicon-git-branch"
>
<path
fill-rule="evenodd"
d="M11.75 2.5a.75.75 0 100 1.5.75.75 0 000-1.5zm-2.25.75a2.25 2.25 0 113 2.122V6A2.5 2.5 0 0110 8.5H6a1 1 0 00-1 1v1.128a2.251 2.251 0 11-1.5 0V5.372a2.25 2.25 0 111.5 0v1.836A2.492 2.492 0 016 7h4a1 1 0 001-1v-.628A2.25 2.25 0 019.5 3.25zM4.25 12a.75.75 0 100 1.5.75.75 0 000-1.5zM3.5 3.25a.75.75 0 111.5 0 .75.75 0 01-1.5 0z"
></path>
</svg>
main
</span>
<span class="Label mr-1 Label--success">Stable</span>
<img
width="80px"
src="https://raw.githubusercontent.com/frontendnetwork/veganify/main/public/img/hero_icon.png"
align="right"
alt="Veganify Logo"
/>
<h1>Localization status</h1>
<table>
<thead>
<tr>
<th>Language</th>
<th>Language Code</th>
<th>Translated</th>
<th>Validated</th>
</tr>
</thead>
<tbody>
<tr>
<td>English</td>
<td><code>en</code></td>
<td>100%</td>
<td>✔︎ (Philip)</td>
</tr>
<tr>
<td>German</td>
<td><code>de</code></td>
<td>100%</td>
<td>✔︎ (Philip, Max)</td>
</tr>
<tr>
<td>French</td>
<td><code>fr</code></td>
<td>100%</td>
<td>✔︎ (Léa)</td>
</tr>
<tr>
<td>Spanish</td>
<td><code>es</code></td>
<td>100%</td>
<td></td>
</tr>
<tr>
<td>Polish</td>
<td><code>pl</code></td>
<td>99%</td>
<td>✔︎ (Lukem)</td>
</tr>
<tr>
<td>Czech</td>
<td><code>cz</code></td>
<td>100%</td>
<td>✔︎ (Michal)</td>
</tr>
</tbody>
</table>
<h2 id="help-us-with-localization">Help us with localization</h2>
<p>
Localizations can be found in <code>src/locales</code>. All language
files are in a <code>[countrycode].json</code> file. Please check our
translations if there is already one localization file available in the
language you are speaking. Otherwise, please create a new json file and
duplicate the contents of the <code>en.json</code> file and translate
the strings to your language.
</p>
<p>
Legal documents and everything that is only available in English and/or German should not be translated. This would require us to get a Laywer everytime we got a new translation. Thank you very much!
</p>
<h3 id="optional-steps">Optional steps</h3>
<p>
If you want to help us make the localization process faster, you can also modify the following files:
</p>
<ul>
<li><strong><code>next.config.js</code></strong>: Add your locale ({yourlocale}) to the configuration:<br>
<pre class="highlight"><code class="lang-javascript">i18n: {
locales: ['de', 'en', 'fr', 'es', 'pl', 'cz', '{yourlocale}'],
defaultLocale: 'en',
},</span></code></pre>
</li>
<li><strong><code>src/pages/more.tsx</code></strong>: Add your locale to the change language page:<br>
<pre class="highlight"><code class="lang-javascript">&lt;Link
className=&quot;nolink&quot;
href=&quot;/more&quot;
locale=&quot;{yourlocale}&quot;
onClick={() =&gt; handleLanguageChange(&quot;{yourlocale}&quot;)}
&gt;
&lt;div
className={router.locale === &quot;{yourlocale}&quot; ? &quot;option active&quot; : &quot;option&quot;}
&gt;
&lt;input
className=&quot;form-check-input&quot;
type=&quot;radio&quot;
name=&quot;flexRadioDefault&quot;
checked={router.locale === &quot;{yourlocale}&quot;}
/&gt;
&lt;span className=&quot;price&quot;&gt;{t(&quot;translation_string_for_your_locale&quot;)}&lt;/span&gt;
&lt;/div&gt;
&lt;/Link&gt;</span></code></pre>
</li>
<li><strong><code>locales/[...].json</code></strong>: Add the translation_string_for_your_locale for your locale to the other locales:<br>
<pre class="highlight"><code class="lang-json">/* src/locales/en.json */
"More": {
...
"english": "English",
"german": "German",
"spanish": "Spanish",
"french": "French",
"polish": "Polish",
"cursed": "Cursed",
"czech": "Czech",
"translation_string_for_your_locale": "Your locale in e.g. english"
},</span></code></pre>
</li>
</ul>
<footer class="border-top">
<p class="color-fg-muted mt-2">
&copy; 2024 <a href="//veganify.app">Veganify</a> - Licensed
under
<a
href="https://github.com/JokeNetwork/veganify/blob/main/LICENSE"
>MIT</a
>
</p>
</footer>
</div>
<script src="assets/prism.js"></script>
</body>
</html>

31
jest.config.ts Normal file
View file

@ -0,0 +1,31 @@
import type { Config } from "jest";
import nextJest from "next/jest";
const createJestConfig = nextJest({
dir: "./",
});
const customJestConfig: Config = {
setupFilesAfterEnv: ["<rootDir>/src/tests/setup.ts"],
testEnvironment: "jest-environment-jsdom",
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
},
testMatch: ["**/*.test.ts", "**/*.test.tsx"],
collectCoverage: true,
coverageDirectory: "coverage",
coverageReporters: ["text", "lcov", "json"],
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json"],
testPathIgnorePatterns: [
"<rootDir>/node_modules/",
"<rootDir>/.next/",
"<rootDir>/playwright/",
],
transformIgnorePatterns: [
"/node_modules/",
"^.+\\.module\\.(css|sass|scss)$",
],
coverageProvider: "v8",
};
export default createJestConfig(customJestConfig);

View file

@ -1,28 +0,0 @@
const million = require("million/compiler");
/** @type {import('next').NextConfig} */
let nextConfig = {
output: "standalone",
reactStrictMode: true,
productionBrowserSourceMaps: true,
i18n: {
locales: ["de", "en", "fr", "es", "pl", "cz"],
defaultLocale: "en",
},
async rewrites() {
return [
{
source: "/datenschutz",
destination: "/privacy-policy",
},
];
},
};
const millionConfig = {
auto: { rsc: true },
};
nextConfig = million.next(nextConfig, millionConfig);
module.exports = nextConfig;

33
next.config.ts Normal file
View file

@ -0,0 +1,33 @@
import million from "million/compiler";
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin();
const baseConfig: NextConfig = {
output: "standalone",
reactStrictMode: true,
productionBrowserSourceMaps: true,
sassOptions: {
silenceDeprecations: ["legacy-js-api"],
},
async rewrites() {
return [
{
source: "/datenschutz",
destination: "/privacy-policy",
},
];
},
};
const millionConfig = {
auto: { rsc: true },
};
// Apply Million's optimization
// eslint-disable-next-line import/no-named-as-default-member
const nextConfig: NextConfig = million.next(baseConfig, millionConfig);
// Apply next-intl plugin and export the final config
export default withNextIntl(nextConfig);

9832
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -3,43 +3,93 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"preinstall": "npx only-allow pnpm",
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start -p 1030",
"stage": "next start -p 1031",
"lint": "next lint",
"test": "playwright test"
"lint:fix": "next lint --fix",
"type-check": "tsc --noEmit",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:e2e": "playwright test"
},
"packageManager": "pnpm@9.12.1",
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix"
]
},
"dependencies": {
"@ducanh2912/next-pwa": "^10.2.9",
"@ericblade/quagga2": "^1.8.4",
"@frontendnetwork/veganify": "^1.2.5",
"@types/node": "22.5.4",
"@types/react-dom": "18.3.0",
"eslint-config-sznm": "^2.0.2",
"@frontendnetwork/veganify": "^1.2.9",
"@types/node": "22.8.1",
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
"jest-worker": "^29.7.0",
"million": "^2.6.4",
"next": "14.2.14",
"next-intl": "^3.17.2",
"next": "15.0.1",
"next-intl": "^3.23.5",
"nookies": "^2.5.2",
"react": "18.3.1",
"react-dom": "18.3.1",
"sass": "^1.79.4",
"prom-client": "^15.1.3",
"react": "19.0.0-rc-69d4b800-20241021",
"react-dom": "19.0.0-rc-69d4b800-20241021",
"sass": "^1.80.4",
"sharp": "^0.33.5",
"typescript": "5.5.4"
"typescript": "5.6.3"
},
"devDependencies": {
"@playwright/test": "^1.46.1",
"@types/react": "^18.3.3",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"@typescript-eslint/typescript-estree": "^8.8.0",
"eslint": "8.56.0",
"eslint-config-next": "^14.2.5",
"eslint-plugin-import": "^2.31.0"
"@playwright/test": "^1.48.0",
"@testing-library/jest-dom": "^6.6.2",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
"@types/jest": "^29.5.14",
"@types/react": "npm:types-react@19.0.0-rc.1",
"@typescript-eslint/eslint-plugin": "^8.11.0",
"@typescript-eslint/parser": "^8.11.0",
"@typescript-eslint/typescript-estree": "^8.11.0",
"eslint": "9.13.0",
"eslint-config-next": "15.0.1",
"eslint-config-sznm": "^2.0.3",
"eslint-import-resolver-typescript": "^3.6.3",
"eslint-plugin-import": "^2.31.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"ts-node": "^10.9.2"
},
"volta": {
"node": "18.16.0",
"yarn": "1.22.19"
"node": "20.0.0"
},
"pnpm": {
"overrides": {
"@types/react": "npm:types-react@19.0.0-rc.1",
"@types/react-dom": "npm:types-react-dom@19.0.0-rc.1",
"eslint": "9.13.0",
"react": "19.0.0-rc-69d4b800-20241021",
"react-dom": "19.0.0-rc-69d4b800-20241021",
"@typescript-eslint/eslint-plugin": "^8.11.0",
"@typescript-eslint/parser": "^8.11.0",
"@typescript-eslint/typescript-estree": "^8.11.0",
"uuid": "3.4.0",
"request": "2.88.2",
"core-js": "^3.36.0",
"glob": "^10.3.10",
"sourcemap-codec": "^1.4.8"
},
"peerDependencyRules": {
"allowedVersions": {
"eslint": "9.13.0",
"react": "19.0.0-rc-69d4b800-20241021"
},
"ignoreMissing": [
"@babel/core",
"webpack",
"react",
"react-dom",
"eslint"
]
}
}
}

5
playwright.config.ts Normal file
View file

@ -0,0 +1,5 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testMatch: ["**/*.spec.ts"],
});

9101
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load diff

1
project.inlang/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
cache

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

@ -0,0 +1,28 @@
import { getTranslations } from "next-intl/server";
import Container from "@/components/elements/container";
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 "";
}
}
export default async function Impressum() {
const t = await getTranslations();
const impressum = await getImpressum();
return (
<Container heading={t("More.imprint")}>
<p className="small">{t("Privacy.germanonly")}</p>
<div dangerouslySetInnerHTML={{ __html: impressum }} />
</Container>
);
}

View file

@ -0,0 +1,10 @@
import Container from "@/components/elements/container";
import { IngredientsForm } from "@/components/IngredientsCheck/IngredientsForm";
export default function IngredientsPage() {
return (
<Container logo={false} backButton={false}>
<IngredientsForm />
</Container>
);
}

View file

@ -0,0 +1,71 @@
import "@/styles/style.scss";
import type { Metadata, Viewport } from "next";
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import { ReactNode } from "react";
import Nav from "@/components/nav";
export const metadata: Metadata = {
title: "Is it vegan? Veganify",
description:
"Are you unsure whether a product is vegan or not? With Veganify you can scan the bar code of an item while shopping and check whether it is vegan or not and that without a lot of other unnecessary information! Try it out now!",
openGraph: {
title: "Veganify",
type: "website",
url: "https://veganify.app",
images: [{ url: "https://veganify.app/img/og_image.png" }],
siteName: "Veganify",
},
twitter: {
card: "summary_large_image",
images: [{ url: "https://veganify.app/img/og_image.png", alt: "Veganify" }],
},
appleWebApp: {
capable: true,
title: "Veganify",
statusBarStyle: "default",
},
manifest: "/manifest.json",
icons: {
icon: "../favicon.ico",
apple: "../img/icon.png",
},
applicationName: "Veganify",
};
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#7f8fa6" },
{ media: "(prefers-color-scheme: dark)", color: "#000000" },
],
width: "device-width",
initialScale: 1,
maximumScale: 1,
viewportFit: "cover",
};
export default async function LocaleLayout(props: {
children: ReactNode;
params: Promise<{ locale: string }>;
}) {
const params = await props.params;
const { locale } = params;
const { children } = props;
const messages = await getMessages();
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>
<div id="modal-root" />
<Nav />
{children}
</NextIntlClientProvider>
</body>
</html>
);
}

View file

@ -0,0 +1,178 @@
"use client";
import Image from "next/image";
import { useLocale, useTranslations } from "next-intl";
import { setCookie } from "nookies";
import Container from "@/components/elements/container";
import SupportOption from "@/components/elements/contents/donate";
import ModalWrapper from "@/components/elements/modalwrapper";
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();
function handleLanguageChange(locale: string) {
setCookie(null, "NEXT_LOCALE", locale, {
maxAge: 30 * 24 * 60 * 60, // 30 days
path: "/",
});
}
return (
<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>
<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="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)}
>
<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"
/>
</div>
</div>
</Container>
);
}

View file

@ -1,15 +1,19 @@
import { GetStaticPropsContext } from "next";
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";
import Nav from "@/components/nav";
export const metadata: Metadata = {
title: "Veganify - Check if products are vegan",
description: "Scan barcodes to check if products are vegan",
};
export default function Home() {
return (
<>
<div id="modal-root"></div>
<InstallPrompt />
<Shortcut />
<Nav />
@ -24,11 +28,3 @@ export default function Home() {
</>
);
}
export async function getStaticProps({ locale }: GetStaticPropsContext) {
return {
props: {
messages: (await import(`../locales/${locale}.json`)).default,
},
};
}

View file

@ -0,0 +1,31 @@
import { getTranslations } from "next-intl/server";
import Container from "@/components/elements/container";
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 "";
}
}
export default async function PrivacyPolicy() {
const t = await getTranslations("Privacy");
const datenschutz = await getPrivacyPolicy();
return (
<Container>
<p className="small">{t("germanonly")}</p>
<div
className="privacy"
dangerouslySetInnerHTML={{ __html: datenschutz }}
/>
</Container>
);
}

View file

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

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,167 @@
"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 LicenseModalContent from "../shared/LicenseModalContent";
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="?"
>
<LicenseModalContent />
</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,60 @@
"use client";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { FormEvent } from "react";
import ScanButton from "@/components/Scanner";
interface SearchFormProps {
barcode: string;
loading: boolean;
onBarcodeChange: (barcode: string) => void;
onSubmit: (barcode: string, e?: 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")}>
<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, FormEvent } 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?: 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,216 @@
import Veganify from "@frontendnetwork/veganify";
import { fetchProduct } from "./product-actions";
// Mock Veganify
jest.mock("@frontendnetwork/veganify", () => ({
getProductByBarcode: jest.fn(),
}));
describe("fetchProduct", () => {
const originalError = console.error;
beforeEach(() => {
console.error = jest.fn();
jest.clearAllMocks();
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValue({
product: {
name: "Test Product",
vegan: true,
},
sources: {
openFoodFacts: true,
},
status: 200,
});
});
afterEach(() => {
console.error = originalError;
});
describe("successful responses", () => {
test("returns product and sources when available", async () => {
const mockResponse = {
product: {
name: "Test Product",
vegan: true,
ingredients: ["water", "sugar"],
},
sources: {
openFoodFacts: true,
community: false,
},
status: 200,
};
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValueOnce(
mockResponse
);
const result = await fetchProduct("4000417025005");
expect(result).toEqual(mockResponse);
});
test("returns only status when no product found", async () => {
const mockResponse = {
status: 404,
};
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValueOnce(
mockResponse
);
const result = await fetchProduct("4000417025005");
expect(result).toEqual({ status: 404 });
});
});
describe("error handling", () => {
test("throws error when API call fails", async () => {
const mockError = new Error("API Error");
(Veganify.getProductByBarcode as jest.Mock).mockRejectedValueOnce(
mockError
);
await expect(fetchProduct("4000417025005")).rejects.toThrow("API Error");
});
test("handles invalid barcode format", async () => {
const mockResponse = {
status: 400,
};
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValueOnce(
mockResponse
);
const result = await fetchProduct("invalid");
expect(result).toEqual({ status: 400 });
});
});
describe("environment handling", () => {
const mockResponse = {
product: {
name: "Test Product",
},
sources: {
openFoodFacts: true,
},
status: 200,
};
beforeEach(() => {
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValue(
mockResponse
);
});
test("uses staging flag when NEXT_PUBLIC_STAGING is true", async () => {
process.env.NEXT_PUBLIC_STAGING = "true";
await fetchProduct("4000417025005");
expect(Veganify.getProductByBarcode).toHaveBeenCalledWith(
"4000417025005",
true
);
});
test("uses production when NEXT_PUBLIC_STAGING is false", async () => {
process.env.NEXT_PUBLIC_STAGING = "false";
await fetchProduct("4000417025005");
expect(Veganify.getProductByBarcode).toHaveBeenCalledWith(
"4000417025005",
false
);
});
test("defaults to production when NEXT_PUBLIC_STAGING is undefined", async () => {
process.env.NEXT_PUBLIC_STAGING = undefined;
await fetchProduct("4000417025005");
expect(Veganify.getProductByBarcode).toHaveBeenCalledWith(
"4000417025005",
false
);
});
});
describe("edge cases", () => {
test("handles empty response", async () => {
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValueOnce({
status: 200,
});
const result = await fetchProduct("4000417025005");
expect(result).toEqual({ status: 200 });
});
test("handles malformed response", async () => {
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValueOnce({
unexpected: "data",
status: 200,
});
const result = await fetchProduct("4000417025005");
expect(result).toEqual({ status: 200 });
});
});
describe("input validation", () => {
test("handles empty barcode", async () => {
const mockResponse = {
status: 400,
};
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValueOnce(
mockResponse
);
const result = await fetchProduct("");
expect(result).toEqual({ status: 400 });
});
test("handles very long barcode", async () => {
const longBarcode = "1".repeat(100);
const mockResponse = {
product: {
name: "Test Product",
},
sources: {
openFoodFacts: true,
},
status: 200,
};
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValueOnce(
mockResponse
);
const result = await fetchProduct(longBarcode);
expect(result).toEqual(mockResponse);
});
test("handles special characters in barcode", async () => {
const specialBarcode = "123!@#456";
const mockResponse = {
product: {
name: "Test Product",
},
sources: {
openFoodFacts: true,
},
status: 200,
};
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValueOnce(
mockResponse
);
const result = await fetchProduct(specialBarcode);
expect(result).toEqual(mockResponse);
});
});
});

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,157 @@
import { ProductResult } from "@/models/ProductResults";
import { getProductState } from "./product-helpers";
describe("getProductState", () => {
describe("getVeganState", () => {
test("returns correct classes for vegan states", () => {
const testCases: [ProductResult, string][] = [
[{ vegan: true } as ProductResult, "vegan icon-ok"],
[{ vegan: false } as ProductResult, "non-vegan icon-cancel"],
[{ vegan: "n/a" } as ProductResult, "unknown icon-help"],
[{ vegan: undefined } as ProductResult, "unknown icon-help"],
];
testCases.forEach(([input, expected]) => {
const result = getProductState(input);
expect(result.vegan).toBe(expected);
});
});
test("handles vegetarian states", () => {
const testCases: [ProductResult, string][] = [
[{ vegetarian: true } as ProductResult, "vegan icon-ok"],
[{ vegetarian: false } as ProductResult, "non-vegan icon-cancel"],
[{ vegetarian: "n/a" } as ProductResult, "unknown icon-help"],
[{ vegetarian: undefined } as ProductResult, "unknown icon-help"],
];
testCases.forEach(([input, expected]) => {
const result = getProductState(input);
expect(result.vegetarian).toBe(expected);
});
});
test("handles animal test free states", () => {
const testCases: [ProductResult, string][] = [
[{ animaltestfree: true } as ProductResult, "vegan icon-ok"],
[{ animaltestfree: false } as ProductResult, "non-vegan icon-cancel"],
[{ animaltestfree: "n/a" } as ProductResult, "unknown icon-help"],
[{ animaltestfree: undefined } as ProductResult, "unknown icon-help"],
];
testCases.forEach(([input, expected]) => {
const result = getProductState(input);
expect(result.animaltestfree).toBe(expected);
});
});
test("handles palm oil states", () => {
const testCases: [ProductResult, string][] = [
[{ palmoil: true } as ProductResult, "vegan icon-ok"],
[{ palmoil: false } as ProductResult, "non-vegan icon-cancel"],
[{ palmoil: "n/a" } as ProductResult, "unknown icon-help"],
[{ palmoil: undefined } as ProductResult, "unknown icon-help"],
];
testCases.forEach(([input, expected]) => {
const result = getProductState(input);
expect(result.palmoil).toBe(expected);
});
});
});
describe("getNutriscoreClass", () => {
test("handles valid nutriscore grades", () => {
const testCases: [string, { score: string; className: string }][] = [
["A", { score: "nutri_a icon-a", className: "nutri_a" }],
["B", { score: "nutri_b icon-b", className: "nutri_b" }],
["C", { score: "nutri_c icon-c", className: "nutri_c" }],
["D", { score: "nutri_d icon-d", className: "nutri_d" }],
["E", { score: "nutri_e icon-e", className: "nutri_e" }],
];
testCases.forEach(([grade, expected]) => {
const result = getProductState({ nutriscore: grade } as ProductResult);
expect(result.nutriscore).toEqual(expected);
});
});
test("handles case-insensitive nutriscore grades", () => {
const input = { nutriscore: "a" } as ProductResult;
const result = getProductState(input);
expect(result.nutriscore).toEqual({
score: "nutri_a icon-a",
className: "nutri_a",
});
});
test("handles invalid nutriscore grades", () => {
const testCases: (string | undefined)[] = [
"F",
"X",
"123",
"n/a",
undefined,
];
testCases.forEach((grade) => {
const result = getProductState({ nutriscore: grade } as ProductResult);
expect(result.nutriscore).toEqual({
score: "unknown icon-help",
className: "",
});
});
});
test("handles general grade scores similarly to nutriscore", () => {
const testCases: [string, { score: string; className: string }][] = [
["A", { score: "nutri_a icon-a", className: "nutri_a" }],
["B", { score: "nutri_b icon-b", className: "nutri_b" }],
["C", { score: "nutri_c icon-c", className: "nutri_c" }],
];
testCases.forEach(([grade, expected]) => {
const result = getProductState({ grade } as ProductResult);
expect(result.grade).toEqual(expected);
});
});
});
test("handles complete product data", () => {
const input: ProductResult = {
productname: "Foo Chocolate Bar",
vegan: true,
vegetarian: false,
animaltestfree: "n/a",
palmoil: undefined,
nutriscore: "A",
grade: "B",
};
const result = getProductState(input);
expect(result).toEqual({
vegan: "vegan icon-ok",
vegetarian: "non-vegan icon-cancel",
animaltestfree: "unknown icon-help",
palmoil: "unknown icon-help",
nutriscore: { score: "nutri_a icon-a", className: "nutri_a" },
grade: { score: "nutri_b icon-b", className: "nutri_b" },
});
});
test("handles empty product data", () => {
const input = {} as ProductResult;
const result = getProductState(input);
expect(result).toEqual({
vegan: "unknown icon-help",
vegetarian: "unknown icon-help",
animaltestfree: "unknown icon-help",
palmoil: "unknown icon-help",
nutriscore: { score: "unknown icon-help", className: "" },
grade: { score: "unknown icon-help", className: "" },
});
});
});

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,110 @@
"use client";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { FormEvent, useState } from "react";
import { IngredientResult } from "./models/IngredientResult";
import { ResultDisplay } from "./ResultsDisplay";
import { checkIngredients } from "./utils/actions";
import { preprocessIngredients } from "./utils/preprocessIngredients";
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: FormEvent<HTMLFormElement>) {
event.preventDefault();
setResult({ vegan: null, surelyVegan: [], notVegan: [], maybeVegan: [] });
setError(null);
const formData = new FormData(event.currentTarget);
const rawIngredients = formData.get("ingredients") as string;
if (!rawIngredients.trim()) {
setError(t("cannotbeempty"));
return;
}
setLoading(true);
try {
const processedIngredients = preprocessIngredients(rawIngredients);
const ingredientsString = processedIngredients.join(", ");
const data = await checkIngredients(ingredientsString);
setResult(data);
} catch (error) {
console.error("Error processing ingredients:", 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,44 @@
"use client";
import ModalWrapper from "@/components/elements/modalwrapper";
import LicenseModalContent from "../shared/LicenseModalContent";
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="?"
>
<LicenseModalContent />
</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,136 @@
import Veganify from "@frontendnetwork/veganify";
jest.mock("@frontendnetwork/veganify", () => ({
getProductByBarcode: jest.fn(),
}));
async function testFetchProduct(barcode: string) {
try {
const data = await Veganify.getProductByBarcode(
barcode,
process.env.NEXT_PUBLIC_STAGING === "true"
);
if (data && "product" in data && "sources" in data) {
return {
product: data.product,
sources: data.sources,
status: data.status,
};
} else if (data && "status" in data) {
return {
status: data.status,
};
}
throw new Error("Invalid response format");
} catch (error) {
console.error("Product fetch error:", error);
throw error;
}
}
describe("fetchProduct", () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset environment variables
process.env.NEXT_PUBLIC_STAGING = undefined;
});
it("returns product and sources when available", async () => {
const mockResponse = {
product: {
name: "Test Product",
vegan: true,
},
sources: {
openFoodFacts: true,
},
status: 200,
};
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValueOnce(
mockResponse
);
const result = await testFetchProduct("4000417025005");
expect(result).toEqual(mockResponse);
expect(Veganify.getProductByBarcode).toHaveBeenCalledWith(
"4000417025005",
false
);
});
it("returns only status when no product found", async () => {
const mockResponse = {
status: 404,
};
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValueOnce(
mockResponse
);
const result = await testFetchProduct("4000417025005");
expect(result).toEqual({ status: 404 });
});
it("throws error when API call fails", async () => {
const mockError = new Error("API Error");
(Veganify.getProductByBarcode as jest.Mock).mockRejectedValueOnce(
mockError
);
await expect(testFetchProduct("4000417025005")).rejects.toThrow(
"API Error"
);
});
it("uses staging flag correctly", async () => {
const mockResponse = {
product: {
name: "Test Product",
vegan: true,
},
sources: {
openFoodFacts: true,
},
status: 200,
};
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValue(mockResponse);
// Test with staging true
process.env.NEXT_PUBLIC_STAGING = "true";
await testFetchProduct("4000417025005");
expect(Veganify.getProductByBarcode).toHaveBeenLastCalledWith(
"4000417025005",
true
);
// Test with staging false
process.env.NEXT_PUBLIC_STAGING = "false";
await testFetchProduct("4000417025005");
expect(Veganify.getProductByBarcode).toHaveBeenLastCalledWith(
"4000417025005",
false
);
// Test with staging undefined
process.env.NEXT_PUBLIC_STAGING = undefined;
await testFetchProduct("4000417025005");
expect(Veganify.getProductByBarcode).toHaveBeenLastCalledWith(
"4000417025005",
false
);
});
it("handles invalid response format", async () => {
(Veganify.getProductByBarcode as jest.Mock).mockResolvedValueOnce({
invalidField: "invalid",
});
await expect(testFetchProduct("4000417025005")).rejects.toThrow(
"Invalid response format"
);
});
});

View file

@ -0,0 +1,27 @@
"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) {
console.error(error);
throw new Error(`Failed to check ingredients`);
}
}

View file

@ -0,0 +1,167 @@
import { preprocessIngredients } from "./preprocessIngredients";
describe("preprocessIngredients", () => {
describe("percentage handling", () => {
test("removes simple percentages", () => {
expect(preprocessIngredients("water 80%, salt 20%")).toEqual([
"water",
"salt",
]);
});
test("removes decimal percentages", () => {
expect(preprocessIngredients("water 80.5%, salt 19.5%")).toEqual([
"water",
"salt",
]);
});
});
describe("separator handling", () => {
test("splits by comma", () => {
expect(preprocessIngredients("water,salt,sugar")).toEqual([
"water",
"salt",
"sugar",
]);
});
test("converts colons to commas", () => {
expect(preprocessIngredients("water:salt:sugar")).toEqual([
"water",
"salt",
"sugar",
]);
});
test("handles mixed separators", () => {
expect(preprocessIngredients("water:salt,sugar:pepper")).toEqual([
"water",
"salt",
"sugar",
"pepper",
]);
});
});
describe("whitespace handling", () => {
test("trims whitespace", () => {
expect(preprocessIngredients(" water , salt , sugar ")).toEqual([
"water",
"salt",
"sugar",
]);
});
test("preserves multiple spaces within ingredients", () => {
expect(preprocessIngredients("water salt, sugar")).toEqual([
"water salt",
"sugar",
]);
});
});
describe("parentheses handling", () => {
test("combines parenthetical content", () => {
expect(preprocessIngredients("water (filtered)")).toEqual([
"water filtered",
]);
});
test("combines multiple parenthetical contents", () => {
expect(preprocessIngredients("water (filtered) (purified)")).toEqual([
"water filtered purified",
]);
});
test("handles nested parentheses", () => {
expect(preprocessIngredients("salt (sea salt (iodized))")).toEqual([
"salt sea salt iodized",
]);
});
});
describe("number handling", () => {
test("preserves vitamin numbers", () => {
expect(preprocessIngredients("vitamin b12, vitamin d3")).toEqual([
"vitamin b12",
"vitamin d3",
]);
});
test("preserves E-numbers", () => {
expect(preprocessIngredients("e150a, e627")).toEqual(["e150a", "e627"]);
});
});
describe("deduplication", () => {
test("removes exact duplicates", () => {
expect(preprocessIngredients("water, water, salt, salt")).toEqual([
"water",
"salt",
]);
});
test("removes substring duplicates", () => {
expect(preprocessIngredients("coconut milk, coconut")).toEqual([
"coconut milk",
]);
});
});
describe("complex real-world cases", () => {
test("handles complex ingredient list", () => {
const input =
"Water (75%), Coconut Milk (15.5%), Sugar (raw) 5%, Salt (sea) 4.5%: Natural Flavoring";
expect(preprocessIngredients(input)).toEqual([
"Water",
"Coconut Milk",
"Sugar raw",
"Salt sea",
"Natural Flavoring",
]);
});
test("handles ingredients with additives", () => {
expect(
preprocessIngredients(
"water, sugar (brown), salt (sea salt), vitamin b12"
)
).toEqual(["water", "sugar brown", "salt sea salt", "vitamin b12"]);
});
});
describe("edge cases", () => {
test("handles empty input", () => {
expect(preprocessIngredients("")).toEqual([]);
});
test("handles input with only separators", () => {
expect(preprocessIngredients(",,,:::,,,")).toEqual([]);
});
test("handles input with only spaces", () => {
expect(preprocessIngredients(" ")).toEqual([]);
});
test("handles special characters", () => {
expect(preprocessIngredients("()[]{}")).toEqual(["[]{}"]);
});
});
describe("error handling", () => {
test("handles null or undefined", () => {
// @ts-expect-error Testing invalid input
expect(preprocessIngredients(null)).toEqual([]);
// @ts-expect-error Testing invalid input
expect(preprocessIngredients(undefined)).toEqual([]);
});
test("handles non-string input", () => {
// @ts-expect-error Testing invalid input
expect(preprocessIngredients(123)).toEqual([]);
// @ts-expect-error Testing invalid input
expect(preprocessIngredients({})).toEqual([]);
});
});
});

View file

@ -0,0 +1,27 @@
export function preprocessIngredients(input: string): string[] {
if (!input || typeof input !== "string") return [];
const processed = input
.replace(/\d+(\.\d+)?%/g, "")
.replace(/\b\w+\s+\d+/g, (match) => match.replace(/\d+$/, ""))
.replace(/:/g, ",");
return processed
.split(",")
.map((item) => item.trim().replace(/\.$/, ""))
.filter(Boolean)
.reduce((acc, item) => {
const parts = item
.split(/\s+(?=\(\*)/)
.map((part) => part.replace(/[()]/g, "").trim());
return acc.concat(parts);
}, [] as string[])
.filter(
(item, index, self) =>
self.indexOf(item) === index &&
!self.some(
(other, otherIndex) =>
otherIndex !== index && other.includes(item) && other !== item
)
);
}

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,145 @@
"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}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleClose();
}
}}
role="button"
tabIndex={0}
aria-label="Close scanner"
/>
</div>
<div className="flex-item">
<span
id="switch-camera"
className="icon-flipcamera"
onClick={handleCameraSwitch}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleCameraSwitch();
}
}}
role="button"
tabIndex={0}
aria-label="Switch camera"
/>
</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,7 +1,16 @@
import Router from 'next/router';
"use client";
const BackButton = () => (
<span onClick={() => Router.back()} style={{cursor: "pointer"}} className="icon-left-open back"/>
);
import { useRouter } from "next/navigation";
export default BackButton;
const BackButton = () => {
const router = useRouter();
return (
<span
onClick={() => router.back()}
style={{ cursor: "pointer" }}
className="icon-left-open back"
/>
);
};
export default BackButton;

View file

@ -1,506 +0,0 @@
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,41 +1,51 @@
import Image from "next/image";
import Link from "next/link";
import { ReactNode } from "react";
import BackButton from '@/components/button_back'
import BackButton from "@/components/button_back";
interface ContainerProps {
heading?: string;
headingstyle?: string;
backbutton?: boolean;
headingStyle?: "center" | { textAlign: string };
backButton?: boolean;
logo?: boolean;
children: React.ReactNode;
children: ReactNode;
}
export default function Container(props: Readonly<ContainerProps>) {
const hasHeading = props.heading ? "true" : "false";
const headingStyle = props.headingstyle ?? undefined;
const hasBackButton = props.backbutton !== false ? true : false;
const hasLogo = props.logo !== false ? true : false;
export default function Container({
heading,
headingStyle,
backButton = true,
logo = true,
children,
}: Readonly<ContainerProps>) {
return (
<div className="container top">
<div id="main">
<div className="form component">
{hasBackButton === true && <BackButton />}
{hasLogo === true && (
{backButton && <BackButton />}
{logo && (
<>
<Link href="/">
<Image src="/./img/Veganify.svg" alt="Logo" className="logo" width={48} height={48} />
<Image
src="/./img/Veganify.svg"
alt="Logo"
className="logo"
width={48}
height={48}
/>
</Link>
<br />
</>
)}
{hasHeading === "true" && (
<h2 style={headingStyle === "center" ? { textAlign: "center" } : {}}>{props.heading}</h2>
{heading && (
<h2
style={headingStyle === "center" ? { textAlign: "center" } : {}}
>
{heading}
</h2>
)}
{props.children}
{children}
</div>
</div>
</div>

View file

@ -2,49 +2,43 @@ import Image from "next/image";
import { useTranslations } from "next-intl";
import { useState } from "react";
const SUPPORT_OPTIONS = {
PAYPAL: {
icon: "icon-paypal",
vendor: "PayPal",
text: "Donate with PayPal",
link: "https://www.paypal.com/donate/?hosted_button_id=J7TEA8GBPN536",
price: "1-15€",
translationKey: "onceviapaypal",
},
KOFI: {
icon: "icon-kofi",
vendor: "Ko-Fi.com",
text: "Sponsor on Ko-Fi",
link: "https://ko-fi.com/veganify",
price: "1-50€",
translationKey: "onceviakofi",
},
GITHUB: {
icon: "icon-github-circled",
vendor: "GitHub",
text: "Sponsor on GitHub",
link: "https://github.com/sponsors/philipbrembeck",
price: "1-100$",
translationKey: "monthlyviagithub",
},
};
const SupportOption = () => {
const t = useTranslations("More");
const [icon, setIcon] = useState<string>("icon-paypal");
const [vendor, setVendor] = useState<string>("PayPal");
const [supportBtnText, setSupportBtnText] = useState<string>("Donate with PayPal");
const [supportBtnLink, setSupportBtnLink] = useState<string>(
"https://www.paypal.com/donate/?hosted_button_id=J7TEA8GBPN536"
);
const [onceChecked, setOnceChecked] = useState<boolean>(true);
const [ghChecked, setGhChecked] = useState<boolean>(false);
const [KofiChecked, setKofiChecked] = useState<boolean>(false);
const [selectedOption, setSelectedOption] = useState(SUPPORT_OPTIONS.PAYPAL);
const handleOptionOnceClick = () => {
setIcon("icon-paypal");
setVendor("PayPal");
setSupportBtnText("Donate with PayPal");
setSupportBtnLink(
"https://www.paypal.com/donate/?hosted_button_id=J7TEA8GBPN536"
);
setOnceChecked(true);
setGhChecked(false);
setKofiChecked(false);
const handleOptionClick = (
option: (typeof SUPPORT_OPTIONS)[keyof typeof SUPPORT_OPTIONS]
) => {
setSelectedOption(option);
};
const handleOptionKofiClick = () => {
setIcon("icon-kofi");
setVendor("Ko-Fi.com");
setSupportBtnText("Sponsor on Ko-Fi");
setSupportBtnLink("https://ko-fi.com/veganify");
setKofiChecked(true);
setOnceChecked(false);
setGhChecked(false);
};
const handleOptionGhClick = () => {
setIcon("icon-github-circled");
setVendor("GitHub");
setSupportBtnText("Sponsor on GitHub");
setSupportBtnLink("https://github.com/sponsors/philipbrembeck");
setGhChecked(true);
setOnceChecked(false);
setKofiChecked(false);
};
return (
<>
<span className="center">
@ -57,57 +51,30 @@ const SupportOption = () => {
/>
<h1>{t("buyusacoffee")}</h1>
</span>
<div
className={`option ${onceChecked ? "active" : ""}`}
id="option_once"
onClick={handleOptionOnceClick}
>
<input
className="form-check-input"
type="radio"
name="flexRadioDefault"
id="once"
checked={onceChecked}
/>
<span className="muted">{t("onceviapaypal")}</span>
<span className="price">1-15</span>
</div>
<div
className={`option ${KofiChecked ? "active" : ""}`}
id="option_kofi"
onClick={handleOptionKofiClick}
>
<input
className="form-check-input"
type="radio"
name="flexRadioDefault"
id="kofi"
checked={KofiChecked}
/>
<span className="muted">{t("onceviakofi")}</span>
<span className="price">1-50</span>
</div>
<div
className={`option ${ghChecked ? "active" : ""}`}
id="option_gh"
onClick={handleOptionGhClick}
>
<input
className="form-check-input"
type="radio"
name="flexRadioDefault"
id="gh"
checked={ghChecked}
/>
<span className="muted">{t("monthlyviagithub")}</span>
<span className="price">1-100$/{t("month")}</span>
</div>
{Object.entries(SUPPORT_OPTIONS).map(([key, option]) => (
<div
key={key}
className={`option ${selectedOption === option ? "active" : ""}`}
id={`option_${key.toLowerCase()}`}
onClick={() => handleOptionClick(option)}
>
<input
className="form-check-input"
type="radio"
name="flexRadioDefault"
id={key.toLowerCase()}
checked={selectedOption === option}
/>
<span className="muted">{t(option.translationKey)}</span>
<span className="price">{option.price}</span>
</div>
))}
<div className="center donate">
<a href={supportBtnLink} id="supportbtn" className="button">
<span className={icon}></span> {supportBtnText}
<a href={selectedOption.link} id="supportbtn" className="button">
<span className={selectedOption.icon}></span> {selectedOption.text}
</a>
<span className="info">
{t("redirect")} <span id="vendor">{vendor}</span>.
{t("redirect")} <span id="vendor">{selectedOption.vendor}</span>.
</span>
</div>
</>

View file

@ -1,12 +1,12 @@
import { useTranslations } from "next-intl";
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
const OLEDMode = () => {
const t = useTranslations("More");
const [isChecked, setIsChecked] = useState<boolean>(false);
const [error, setError] = useState<boolean>(false);
const setThemeColorAttribute = (color: string) => {
const setThemeColorAttribute = useCallback((color: string) => {
const themeColorElement = document.querySelector<HTMLMetaElement>(
'meta[name="theme-color"][media="(prefers-color-scheme: dark)"]'
);
@ -14,40 +14,46 @@ const OLEDMode = () => {
if (themeColorElement) {
themeColorElement.setAttribute("content", color);
}
};
useEffect(() => {
const localStorageValue = localStorage.getItem("oled");
if (
localStorageValue === "true" &&
window.matchMedia("(prefers-color-scheme: dark)").matches
) {
document.documentElement.setAttribute("data-theme", "oled");
setThemeColorAttribute("#000");
setIsChecked(true);
}
}, []);
const handleClick = () => {
if (
!isChecked &&
!window.matchMedia("(prefers-color-scheme: dark)").matches
) {
useEffect(() => {
const initializeOLEDMode = () => {
const localStorageValue = localStorage.getItem("oled");
if (
localStorageValue === "true" &&
window.matchMedia("(prefers-color-scheme: dark)").matches
) {
document.documentElement.setAttribute("data-theme", "oled");
setThemeColorAttribute("#000");
setIsChecked(true);
}
};
initializeOLEDMode();
}, [setThemeColorAttribute]);
const handleClick = useCallback(() => {
const isDarkModePreferred = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches;
if (!isChecked && !isDarkModePreferred) {
setError(true);
return;
}
if (!isChecked) {
document.documentElement.setAttribute("data-theme", "oled");
setThemeColorAttribute("#000");
localStorage.setItem("oled", "true");
} else {
localStorage.clear();
localStorage.removeItem("oled");
document.documentElement.removeAttribute("data-theme");
setThemeColorAttribute("#141414");
}
setIsChecked(!isChecked);
setIsChecked((prevChecked) => !prevChecked);
setError(false);
};
}, [isChecked, setThemeColorAttribute]);
return (
<label htmlFor="oled-switch" className="Grid switcher">

View file

@ -1,75 +1,54 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import React, { useState } from "react";
import React, { useState, ReactNode, ElementType } from "react";
interface Props {
id: string;
children: React.ReactNode;
children: ReactNode;
buttonType: "sup" | "span" | "div";
buttonClass: string;
buttonText: string;
}
const Modal = ({ id, children, buttonType, buttonClass, buttonText }: Props) => {
const Modal = ({
id,
children,
buttonType,
buttonClass,
buttonText,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const openModal = () => {
setIsOpen(true);
const toggleModal = (state: boolean) => {
setIsOpen(state);
};
const closeModal = () => {
setIsOpen(false);
};
const ButtonComponent: ElementType = buttonType;
return (
<>
{buttonType === "sup" && (
<sup
data-target={id}
data-toggle="modal"
className={buttonClass}
onClick={openModal}
>
{buttonText}
</sup>
)}
{buttonType === "span" && (
<span
data-target={id}
data-toggle="modal"
className={buttonClass}
onClick={openModal}
>
{buttonText}
</span>
)}
{buttonType === "div" && (
<div
data-target={id}
data-toggle="modal"
className={buttonClass}
onClick={openModal}
>
{buttonText}
</div>
)}
<ButtonComponent
data-target={id}
data-toggle="modal"
className={buttonClass}
onClick={() => toggleModal(true)}
>
{buttonText}
</ButtonComponent>
{isOpen && (
<div className="modal_view animated fadeInUp open">
<div className="modal_close">
<a
<button
className="btn-dark"
data-dismiss="modal"
onClick={() => {
const modalView = document.querySelector(".modal_view");
if (modalView) {
modalView.classList.add("fadeOutDown");
setTimeout(() => {
setIsOpen(false);
}, 500);
setTimeout(() => toggleModal(false), 500);
}
}}
>
×
</a>
</button>
</div>
{children}
</div>

View file

@ -1,12 +1,20 @@
import React, { useState, useEffect } from "react";
"use client";
import React, {
useState,
useEffect,
useCallback,
useRef,
ReactNode,
} from "react";
import { createPortal } from "react-dom";
interface ModalProps {
id: string;
buttonType: string;
buttonType: "sup" | "span" | "div";
buttonClass: string;
buttonText: string;
children: React.ReactNode;
children: ReactNode;
}
const ModalWrapper = ({
@ -17,12 +25,28 @@ const ModalWrapper = ({
buttonText,
}: ModalProps) => {
const [isOpen, setIsOpen] = useState(false);
const modalRoot =
typeof document !== "undefined"
? document.getElementById("modal-root")
: null;
const [mounted, setMounted] = useState(false);
const modalRootRef = useRef<HTMLElement | null>(null);
useEffect(() => {
setMounted(true);
modalRootRef.current = document.getElementById("modal-root");
return () => setMounted(false);
}, []);
const closeModal = useCallback(() => {
const modalView = document.querySelector(".modal_view");
if (modalView) {
modalView.classList.add("fadeOutDown");
setTimeout(() => {
setIsOpen(false);
}, 500);
}
}, []);
useEffect(() => {
if (!isOpen) return;
const handleEscapeKeyPress = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeModal();
@ -31,17 +55,16 @@ const ModalWrapper = ({
const handleTouchStart = (event: TouchEvent) => {
const touchStartY = event.touches[0].clientY;
let touchEndY;
document.body.addEventListener("touchend", handleTouchEnd);
function handleTouchEnd(event: TouchEvent) {
touchEndY = event.changedTouches[0].clientY;
const handleTouchEnd = (event: TouchEvent) => {
const touchEndY = event.changedTouches[0].clientY;
if (touchEndY - touchStartY > 10) {
closeModal();
}
document.body.removeEventListener("touchend", handleTouchEnd);
}
};
document.body.addEventListener("touchend", handleTouchEnd);
};
document.addEventListener("keydown", handleEscapeKeyPress);
@ -51,66 +74,38 @@ const ModalWrapper = ({
document.removeEventListener("keydown", handleEscapeKeyPress);
document.removeEventListener("touchstart", handleTouchStart);
};
}, []);
}, [isOpen, closeModal]);
const openModal = () => {
setIsOpen(true);
};
const ButtonComponent = buttonType;
const closeModal = () => {
const modalView = document.querySelector(".modal_view");
if (modalView) {
modalView.classList.add("fadeOutDown");
setTimeout(() => {
setIsOpen(false);
}, 500);
}
};
if (!mounted) return null;
return (
<>
{buttonType === "sup" && (
<sup
data-target={id}
data-toggle="modal"
className={buttonClass}
onClick={openModal}
>
{buttonText}
</sup>
)}
{buttonType === "span" && (
<span
data-target={id}
data-toggle="modal"
className={buttonClass}
onClick={openModal}
>
{buttonText}
</span>
)}
{buttonType === "div" && (
<div
data-target={id}
data-toggle="modal"
className={buttonClass}
onClick={openModal}
>
{buttonText}
</div>
)}
<ButtonComponent
data-target={id}
data-toggle="modal"
className={buttonClass}
onClick={() => setIsOpen(true)}
>
{buttonText}
</ButtonComponent>
{isOpen &&
modalRoot &&
modalRootRef.current &&
createPortal(
<div className="modal_view animated fadeInUp open">
<div className="modal_close">
<a className="btn-dark" data-dismiss="modal" onClick={closeModal}>
<button
className="btn-dark"
data-dismiss="modal"
onClick={closeModal}
>
×
</a>
</button>
</div>
{children}
</div>,
modalRoot
modalRootRef.current
)}
</>
);

View file

@ -1,5 +1,6 @@
"use client";
/* eslint-disable @next/next/no-img-element */
/* eslint-disable @typescript-eslint/no-explicit-any */
import Image from "next/image";
import { useTranslations } from "next-intl";
import { useState, useEffect } from "react";
@ -12,18 +13,15 @@ const InstallPrompt = () => {
useEffect(() => {
const pwainstall = getCookie("pwainstall");
if (pwainstall !== "hidden") {
if (typeof window !== "undefined") {
const isIOS =
/iPad|iPhone|iPod/.test(navigator.userAgent) &&
!(window as any).MSStream;
if (
!window.matchMedia("(display-mode: standalone)").matches &&
isIOS &&
window.location.href.indexOf("shortcut") === -1
) {
setShowInstallPrompt(true);
}
if (pwainstall !== "hidden" && typeof window !== "undefined") {
const isIOS =
/iPad|iPhone|iPod/.test(navigator.userAgent) && !("MSStream" in window);
const isNotStandalone = !window.matchMedia("(display-mode: standalone)")
.matches;
const isNotShortcut = window.location.href.indexOf("shortcut") === -1;
if (isIOS && isNotStandalone && isNotShortcut) {
setShowInstallPrompt(true);
}
}
}, []);
@ -38,10 +36,7 @@ const InstallPrompt = () => {
}
return (
<div
id="pwainstall"
style={{ display: showInstallPrompt ? "block" : "none" }}
>
<div id="pwainstall" style={{ display: "block" }}>
<div className="flex-container">
<div className="flex-item" id="pwaclose" onClick={closeInstallPrompt}>
×
@ -88,16 +83,15 @@ const InstallPrompt = () => {
function getCookie(name: string): string | undefined {
const cookie = document.cookie
.split(";")
.find((c) => c.trim().startsWith(name + "="));
if (!cookie) return undefined;
return cookie.split("=")[1];
.find((c) => c.trim().startsWith(`${name}=`));
return cookie ? cookie.split("=")[1] : undefined;
}
function setCookie(name: string, value: string, days: number): void {
const date = new Date();
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
const expires = "expires=" + date.toUTCString();
document.cookie = name + "=" + value + ";" + expires + ";path=/";
const expires = `expires=${date.toUTCString()}`;
document.cookie = `${name}=${value};${expires};path=/`;
}
export default InstallPrompt;

View file

@ -1,165 +1,148 @@
import Image from "next/image";
import { useTranslations } from "next-intl";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useMemo } from "react";
import ModalWrapper from "@/components/elements/modalwrapper";
interface ShareButtonProps {
productName: string | undefined;
productName?: string;
barcode: string;
}
const ShareButton = ({ productName, barcode }: ShareButtonProps) => {
const ShareButton = ({
productName = "Product",
barcode,
}: ShareButtonProps) => {
const t = useTranslations("Check");
const [showButton, setShowButton] = useState<boolean>(false);
const [showButton, setShowButton] = useState(false);
useEffect(() => {
if (navigator.share as (() => Promise<void>) | undefined) {
setShowButton(true);
}
const checkShareAvailability = () => {
if (typeof navigator.share === "function") {
setShowButton(true);
}
};
checkShareAvailability();
}, []);
const text = productName + " - Checked using Veganify";
const text = `${productName} - Checked using Veganify`;
const url = `https://veganify.app/?ean=${barcode}`;
const handleCopyClick = () => {
navigator.clipboard
.writeText(`${text}: ${url}`)
.then(() => {
(document.querySelector(".btn-dark") as HTMLElement)?.click();
})
.catch((error) => {
console.error("Error writing to clipboard:", error);
});
const handleShareClick = (shareUrl: string) => {
window.location.href = shareUrl;
document.querySelector<HTMLElement>(".btn-dark")?.click();
};
const handleMastodonClick = () => {
const mastodonurl = `https://s2f.kytta.dev/?text=${encodeURI(
text
)} https%3A%2F%2Fveganify.app%2F%3Fean%3D${barcode}`;
window.location.href = mastodonurl;
(document.querySelector(".btn-dark") as HTMLElement)?.click();
};
interface ShareOption {
id: string;
text: string;
icon: string;
url: string;
handler?: () => Promise<void>;
}
const handleTweetClick = () => {
const tweetUrl = `https://twitter.com/intent/tweet?url=${url}&text=${encodeURI(
text
)}`;
window.location.href = tweetUrl;
(document.querySelector(".btn-dark") as HTMLElement)?.click();
};
const handleWhatsAppClick = () => {
const whatsappurl = `whatsapp://send?text=${encodeURI(text)} ${url}`;
window.location.href = whatsappurl;
(document.querySelector(".btn-dark") as HTMLElement)?.click();
};
const handleTelegramClick = () => {
const telegramurl = `https://telegram.me/share/url?url=${url}&text=${encodeURI(
text
)}`;
window.location.href = telegramurl;
(document.querySelector(".btn-dark") as HTMLElement)?.click();
};
const handleFacebookClick = () => {
const facebookurl = `https://www.facebook.com/sharer/sharer.php?u=${url}`;
window.location.href = facebookurl;
(document.querySelector(".btn-dark") as HTMLElement)?.click();
};
const handleMessageClick = () => {
const messageurl = `sms:&body=${url} ${text}`;
window.location.href = messageurl;
(document.querySelector(".btn-dark") as HTMLElement)?.click();
};
const handleEmailClick = () => {
const emailurl = `mailto:?body="${url}"&subject=${text}`;
window.location.href = emailurl;
(document.querySelector(".btn-dark") as HTMLElement)?.click();
};
const shareOptions = useMemo<ShareOption[]>(
() => [
{
id: "copy",
text: t("copy"),
icon: "icon-docs",
url: `${text}: ${url}`,
handler: async () => {
await navigator.clipboard.writeText(`${text}: ${url}`);
},
},
{
id: "mastodon",
text: `${t("share")} ${t("on")} Mastodon`,
icon: "icon-mastodon",
url: `https://s2f.kytta.dev/?text=${encodeURI(text)} https%3A%2F%2Fveganify.app%2F%3Fean%3D${barcode}`,
},
{
id: "twitter",
text: `${t("share")} ${t("on")} Twitter`,
icon: "icon-twitter",
url: `https://twitter.com/intent/tweet?url=${url}&text=${encodeURI(text)}`,
},
{
id: "whatsapp",
text: `${t("share")} ${t("on")} WhatsApp`,
icon: "icon-whatsapp",
url: `whatsapp://send?text=${encodeURI(text)} ${url}`,
},
{
id: "telegram",
text: `${t("share")} ${t("on")} Telegram`,
icon: "icon-telegram",
url: `https://telegram.me/share/url?url=${url}&text=${encodeURI(text)}`,
},
{
id: "facebook",
text: `${t("share")} ${t("on")} Facebook`,
icon: "icon-facebook",
url: `https://www.facebook.com/sharer/sharer.php?u=${url}`,
},
{
id: "message",
text: `${t("share")} via message`,
icon: "icon-chat",
url: `sms:&body=${url} ${text}`,
},
{
id: "email",
text: `${t("share")} via e-mail`,
icon: "icon-mail",
url: `mailto:?body="${url}"&subject=${text}`,
},
],
[t, text, url, barcode]
);
return showButton ? (
<span
className="button"
id="share"
onClick={() => {
navigator
.share({
text,
url,
})
.catch((err) => {
console.error(err);
});
navigator.share({ text, url }).catch(console.error);
}}
>
{t("share")}
</span>
) : (
<>
<ModalWrapper
id="share"
buttonType="span"
buttonClass="button"
buttonText={t("share")}
>
<span className="center">
<Image
src="../img/pwainstall_img.svg"
className="heading_img"
width={48}
height={48}
alt="Share"
/>
<h1>{t("share")}</h1>
</span>
<div className="share-btn" id="copy" onClick={handleCopyClick}>
<span className="share-text">{t("copy")}</span>
<span className="share-icon icon-docs"></span>
<ModalWrapper
id="share"
buttonType="span"
buttonClass="button"
buttonText={t("share")}
>
<span className="center">
<Image
src="../img/pwainstall_img.svg"
className="heading_img"
width={48}
height={48}
alt="Share"
/>
<h1>{t("share")}</h1>
</span>
{shareOptions.map(({ id, text, icon, url, handler }) => (
<div
key={id}
className="share-btn"
id={id}
onClick={() =>
handler
? handler()
.then(() => handleShareClick(url))
.catch(console.error)
: handleShareClick(url)
}
>
<span className="share-text">{text}</span>
<span className={`share-icon ${icon}`}></span>
</div>
<div className="share-btn" id="mastodon" onClick={handleMastodonClick}>
<span className="share-text">
{t("share")} {t("on")} Mastodon
</span>
<span className="share-icon icon-mastodon"></span>
</div>
<div className="share-btn" id="twitter" onClick={handleTweetClick}>
<span className="share-text">
{t("share")} {t("on")} Twitter
</span>
<span className="share-icon icon-twitter"></span>
</div>
<div className="share-btn" id="whatsapp" onClick={handleWhatsAppClick}>
<span className="share-text">
{t("share")} {t("on")} WhatsApp
</span>
<span className="share-icon icon-whatsapp"></span>
</div>
<div className="share-btn" id="telegram" onClick={handleTelegramClick}>
<span className="share-text">
{t("share")} {t("on")} Telegram
</span>
<span className="share-icon icon-telegram"></span>
</div>
<div className="share-btn" id="facebook" onClick={handleFacebookClick}>
<span className="share-text">
{t("share")} {t("on")} Facebook
</span>
<span className="share-icon icon-facebook"></span>
</div>
<div className="share-btn" id="message" onClick={handleMessageClick}>
<span className="share-text">{t("share")} via message</span>
<span className="share-icon icon-chat"></span>
</div>
<div className="share-btn" id="email" onClick={handleEmailClick}>
<span className="share-text">{t("share")} via e-mail</span>
<span className="share-icon icon-mail"></span>
</div>
</ModalWrapper>
</>
))}
</ModalWrapper>
);
};

View file

@ -1,32 +1,35 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { useState, useEffect } from "react";
interface ExtendedWindow extends Window {
MSStream?: any;
MSStream?: unknown;
}
const isIOSDevice = (window: ExtendedWindow): boolean => {
return /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
};
const shouldShowShortcut = (window: ExtendedWindow): boolean => {
return (
!window.matchMedia("(display-mode: standalone)").matches &&
isIOSDevice(window) &&
!window.location.href.includes("shortcut")
);
};
const Shortcut = () => {
const t = useTranslations("ShortcutPrompt");
const [showShortcut, setShowShortcut] = useState<boolean>(false);
const [showShortcut, setShowShortcut] = useState(false);
useEffect(() => {
if (typeof window !== "undefined") {
const windowWithMSStream = window as ExtendedWindow;
const isIOS: boolean =
/iPad|iPhone|iPod/.test(navigator.userAgent) &&
!windowWithMSStream.MSStream;
if (
!window.matchMedia("(display-mode: standalone)").matches &&
isIOS &&
window.location.href.indexOf("shortcut") === -1
) {
if (typeof window !== "undefined") {
document.getElementById("mainpage")?.classList.remove("top");
}
if (shouldShowShortcut(windowWithMSStream)) {
document.getElementById("mainpage")?.classList.remove("top");
setShowShortcut(true);
}
}

View file

@ -1,83 +1,92 @@
/* eslint-disable @next/next/no-img-element */
import Image from "next/image";
import { useTranslations } from "next-intl";
export default function Footer() {
const t = useTranslations("Footer");
const d = new Date().getMonth();
interface FooterLinkProps {
href: string;
src: string;
alt: string;
className?: string;
width?: number;
height?: number;
}
function FooterLink({
href,
src,
alt,
className = "labels",
width = 48,
height = 48,
}: FooterLinkProps) {
return (
<>
<footer>
<a
href="https://www.producthunt.com/products/vegancheck-me?utm_source=badge-featured&utm_medium=badge"
target="_blank"
>
<img
src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=396704&theme=neutral"
alt="Veganify | Product Hunt"
height="30"
/>
</a>
<p
dangerouslySetInnerHTML={{
__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>',
}),
}}
/>
{d === 0 ? (
<a href="https://vegc.net/veganuary">
<Image
src="../img/veganuary.svg"
alt="Go to Veganuary"
className="labels"
width={48}
height={48}
/>
</a>
) : (
<a href="https://veganify.app">
<Image
src="../img/veganify_text.svg"
alt="Veganify Logo"
className="labels"
width={48}
height={48}
/>
</a>
)}
<a href="https://github.com/frontendnetwork/veganify">
<Image
src="../img/opensource.svg"
alt="Open Source"
className="labels"
width={48}
height={48}
/>
</a>
<a href="https://www.thegreenwebfoundation.org/green-web-check/?url=https%3A%2F%2Fveganify.app">
<Image
src="../img/greenhosted.svg"
alt="Hosted Green"
className="labels"
width={48}
height={48}
/>
</a>
<a href="https://iplantatree.org/user/Veganify">
<Image
src="../img/treelabel.svg"
alt="We plant trees. We're carbon neutral."
className="labels"
width={48}
height={48}
/>
</a>
</footer>
</>
<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 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>
<a
href="https://www.producthunt.com/products/vegancheck-me?utm_source=badge-featured&utm_medium=badge"
target="_blank"
rel="noopener noreferrer"
>
<Image
src="../img/ph_neutral.svg"
alt="Veganify | Product Hunt"
width={182}
height={40}
/>
</a>
<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"
alt="We plant trees. We're carbon neutral."
/>
</footer>
);
}

View file

@ -1,251 +0,0 @@
import Veganify from "@frontendnetwork/veganify";
import Image from "next/image";
import { useTranslations } from "next-intl";
import React, { useState, FormEvent } from "react";
import ModalWrapper from "@/components/elements/modalwrapper";
const IngredientsCheck = () => {
const t = useTranslations("Ingredients");
const [surelyVegan, setSurelyVegan] = useState<string[]>([]);
const [notVegan, setNotVegan] = useState<string[]>([]);
const [maybeVegan, setMaybeVegan] = useState<string[]>([]);
const [vegan, setVegan] = useState<boolean | null>(null);
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
setVegan(null);
setSurelyVegan([]);
setNotVegan([]);
setMaybeVegan([]);
setError(false);
event.preventDefault();
const ingredients = event.currentTarget.elements.namedItem(
"ingredients"
) as HTMLInputElement;
const checkIngredients = async () => {
setLoading(true);
try {
const data = await Veganify.checkIngredientsList(
ingredients.value,
process.env.NEXT_PUBLIC_STAGING === "true" ? true : false
);
setVegan(data.data.vegan);
setSurelyVegan(data.data.surely_vegan);
setNotVegan(data.data.not_vegan);
setMaybeVegan(data.data.maybe_vegan);
setLoading(false);
} catch (error) {
setError(true);
setLoading(false);
}
};
checkIngredients();
};
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
name="checkingredients"
aria-label={t("submit")}
role="button"
>
<span className="icon-right-open"></span>
</button>
</fieldset>
</form>
{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={
vegan ? "vegan icon-ok" : "non-vegan icon-cancel"
}
></span>
</div>
</div>
{notVegan.length > 0 && (
<>
{notVegan.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="non-vegan icon-cancel"></span>
</div>
</div>
))}
</>
)}
{maybeVegan.length > 0 && (
<>
{maybeVegan.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="maybe-vegan icon-help"></span>
</div>
</div>
))}
</>
)}
{surelyVegan.length > 0 && (
<>
{surelyVegan.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="vegan icon-ok"></span>
</div>
</div>
))}
</>
)}
<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">{t("cannotbeempty")}</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

@ -1,117 +1,64 @@
import { GetStaticPropsContext } from "next";
import Head from "next/head";
import Link from "next/link";
import { useRouter } from "next/router";
"use client";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { Link, usePathname } from "@/i18n/routing";
const NavItem = ({
href,
iconClass,
translationKey,
isActive,
}: {
href: string;
iconClass: string;
translationKey: string;
isActive: boolean;
}) => (
<div className={`flex-item ${isActive ? "active" : ""}`}>
<Link href={href}>
<span className={`icon ${iconClass}`}></span>
<span className="menu-item">{translationKey}</span>
</Link>
</div>
);
export default function Nav() {
const t = useTranslations("Nav");
const router = useRouter();
useEffect(() => {
const localStorageValue = localStorage.getItem("oled");
if (
localStorageValue === "true" &&
window.matchMedia("(prefers-color-scheme: dark)").matches
) {
document.documentElement.setAttribute("data-theme", "oled");
const themeColorMeta = document.querySelector('meta[name="theme-color"][media="(prefers-color-scheme: dark)"]');
if (themeColorMeta) {
themeColorMeta.setAttribute("content", "#000");
}
}
}, []);
const pathname = usePathname();
const navItems = [
{ href: "/", iconClass: "icon-vegancheck", translationKey: t("home") },
{
href: "/ingredients",
iconClass: "icon-ingredients",
translationKey: t("ingredientcheck"),
},
{ href: "/more", iconClass: "icon-ellipsis", translationKey: t("more") },
];
const isMoreActive = [
"/more",
"/tos",
"/privacy-policy",
"/impressum",
].includes(pathname);
return (
<>
<Head>
<title>{t("title")}</title>
<meta name="description" content={t("description")} />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<meta property="og:title" content={t("title")} />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://veganify.app" />
<meta
name="twitter:image:src"
content="https://veganify.app/img/og_image.png"
/>
<meta property="twitter:image:alt" content="Veganify" />
<meta name="twitter:card" content="summary_large_image" />
<meta
property="og:image"
content="https://veganify.app/img/og_image.png"
/>
<meta property="og:image:alt" content="Veganify" />
<meta property="og:site_name" content="Veganify" />
<meta property="og:type" content="object" />
<link rel="shortcut icon" type="image/x-icon" href="../favicon.ico" />
<link rel="apple-touch-icon" href="../img/icon.png" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="msapplication-starturl" content="/" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#7f8fa6" key="pcl" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#141414" key="pcd" />
<meta name="application-name" content="Veganify" />
<meta name="apple-mobile-web-app-title" content="Veganify" />
</Head>
<nav className="nav">
<div className="flex-container">
<div
className={
router.pathname == "/" ? "flex-item active" : "flex-item"
<nav className="nav">
<div className="flex-container">
{navItems.map((item) => (
<NavItem
key={item.href}
href={item.href}
iconClass={item.iconClass}
translationKey={item.translationKey}
isActive={
item.href === "/more" ? isMoreActive : pathname === item.href
}
>
<Link href="/">
<span className="icon icon-vegancheck"></span>
<span className="menu-item">{t("home")}</span>
</Link>
</div>
<div
className={
router.pathname == "/ingredients"
? "flex-item active"
: "flex-item"
}
>
<Link href="/ingredients">
<span className="icon icon-ingredients"></span>
<span className="menu-item">{t("ingredientcheck")}</span>
</Link>
</div>
<div
className={
["/more", "/tos", "/privacy-policy", "/impressum"].includes(
router.pathname
)
? "flex-item active"
: "flex-item"
}
>
<Link href="/more">
<span className="icon icon-ellipsis"></span>
<span className="menu-item">{t("more")}</span>
</Link>
</div>
</div>
</nav>
</>
/>
))}
</div>
</nav>
);
}
export async function getStaticProps({ locale }: GetStaticPropsContext) {
return {
props: {
messages: (await import(`..//locales/${locale}.json`)).default,
},
};
}

View file

@ -0,0 +1,58 @@
import Image from "next/image";
import { useTranslations } from "next-intl";
const LicenseModalContent = () => {
const t = useTranslations("Check");
return (
<>
<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>
</>
);
};
export default LicenseModalContent;

View file

@ -1,13 +0,0 @@
import { GetStaticProps } from "next";
interface Props {
messages: Record<string, string>;
}
export const getStaticProps: GetStaticProps<Props> = async ({ locale }) => {
return {
props: {
messages: require(`/locales/${locale}.json`),
},
};
};

15
src/i18n/request.ts Normal file
View file

@ -0,0 +1,15 @@
import { notFound } from "next/navigation";
import { getRequestConfig } from "next-intl/server";
import { routing } from "./routing";
export default getRequestConfig(async ({ locale }: { locale: string }) => {
// Validate that the incoming `locale` parameter is valid
if (!routing.locales.includes(locale as (typeof routing.locales)[number])) {
notFound();
}
return {
messages: (await import(`../locales/${locale}.json`)).default,
};
});

10
src/i18n/routing.ts Normal file
View file

@ -0,0 +1,10 @@
import { createSharedPathnamesNavigation } from "next-intl/navigation";
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: ["en", "de", "es", "fr", "pl", "cz"],
defaultLocale: "en",
});
export const { Link, redirect, usePathname, useRouter } =
createSharedPathnamesNavigation(routing);

12
src/middleware.ts Normal file
View file

@ -0,0 +1,12 @@
import createMiddleware from "next-intl/middleware";
import { routing } from "./i18n/routing";
export default createMiddleware(routing, {
localeDetection: true,
});
export const config = {
// Match only internationalized pathnames
matcher: ["/", "/(de|en|es|fr|pl|cz)/:path*"],
};

View file

@ -1,5 +1,5 @@
export type ErrorResponse = {
export interface ErrorResponse {
response: {
status: number;
};
};
}

View file

@ -1,4 +1,4 @@
export type FlaggedItem = {
export interface FlaggedItem {
item: string;
index: number;
};
}

View file

@ -1,4 +1,4 @@
export type ProductResult = {
export interface ProductResult {
productname: string;
vegan: boolean | "n/a" | undefined;
vegetarian: boolean | "n/a" | undefined;
@ -6,4 +6,4 @@ export type ProductResult = {
palmoil: boolean | "n/a" | undefined;
nutriscore?: string;
grade?: string;
};
}

View file

@ -1,4 +1,4 @@
export type Sources = {
export interface Sources {
api?: string;
baseuri?: string;
};
}

View file

@ -1,36 +0,0 @@
import { GetStaticPropsContext } from "next";
import { useTranslations } from "next-intl";
import Container from "@/components/elements/container";
import Nav from "@/components/nav";
export default function NotFound() {
const t = useTranslations("404");
return (
<>
<Nav />
<Container heading={t("error404")}>
<p>{t("pagedoesnotexist")}</p>
<p
dangerouslySetInnerHTML={{
__html: t("message", {
statuspage: `<a href="https://stats.uptimerobot.com/LY1gRuP5j6/789004495">${t(
"statuspage"
)}</a>`,
mastodon:
'<a href="https://veganism.social/@vegancheck">Mastodon</a>',
}),
}}
/>
</Container>
</>
);
}
export async function getStaticProps({ locale }: GetStaticPropsContext) {
return {
props: {
messages: (await import(`../locales/${locale}.json`)).default,
},
};
}

View file

@ -1,20 +0,0 @@
import "@/styles/style.scss";
import type { AppProps } from "next/app";
import { useRouter } from "next/router";
import { NextIntlClientProvider } from "next-intl";
export default function App({ Component, pageProps }: AppProps) {
const router = useRouter();
return (
<>
<NextIntlClientProvider
locale={router.locale}
timeZone="Europe/Vienna"
messages={pageProps.messages}
>
<Component {...pageProps} />
</NextIntlClientProvider>
</>
);
}

View file

@ -1,13 +0,0 @@
import { Html, Head, Main, NextScript } from 'next/document'
export default function Document() {
return (
<Html lang="en">
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
)
}

View file

@ -1,38 +0,0 @@
import { GetStaticPropsContext } from "next";
import { useTranslations } from "next-intl";
import React, { useState, useEffect } from "react";
import Container from "@/components/elements/container";
import Nav from "@/components/nav";
const Impressum = () => {
const t = useTranslations();
const [impressum, setImpressum] = useState("");
useEffect(() => {
fetch("https://philipbrembeck.com/impressum.txt", { method: "GET" })
.then((response) => response.text())
.then((text) => setImpressum(text))
.catch((error) => console.error(error));
}, []);
return (
<>
<Nav />
<Container heading={t("More.imprint")}>
<p className="small">{t("Privacy.germanonly")}</p>
<div dangerouslySetInnerHTML={{ __html: impressum }} />
</Container>
</>
);
};
export default Impressum;
export async function getStaticProps({ locale }: GetStaticPropsContext) {
return {
props: {
messages: (await import(`../locales/${locale}.json`)).default,
},
};
}

View file

@ -1,25 +0,0 @@
import { GetStaticPropsContext } from "next";
import Container from "@/components/elements/container";
import IngredientsCheck from "@/components/ingredientscheck";
import Nav from "@/components/nav";
export default function ingredients() {
return (
<>
<div id="modal-root"></div>
<Nav />
<Container logo={false} backbutton={false}>
<IngredientsCheck />
</Container>
</>
);
}
export async function getStaticProps({ locale }: GetStaticPropsContext) {
return {
props: {
messages: (await import(`../locales/${locale}.json`)).default,
},
};
}

View file

@ -1,266 +0,0 @@
import { GetStaticPropsContext } from "next";
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/router";
import { useTranslations } from "next-intl";
import { setCookie } from "nookies";
import Container from "@/components/elements/container";
import SupportOption from "@/components/elements/contents/donate";
import OLEDMode from "@/components/elements/contents/oledmode";
import ModalWrapper from "@/components/elements/modalwrapper";
import Nav from "@/components/nav";
export default function More() {
const router = useRouter();
const t = useTranslations("More");
function handleLanguageChange(locale: string) {
setCookie(null, "NEXT_LOCALE", locale, {
maxAge: 30 * 24 * 60 * 60, // 30 days
path: "/",
});
router.push("/more", undefined, { locale });
}
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>
</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="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>
<Link
className="nolink"
href="/more"
locale="en"
onClick={() => handleLanguageChange("en")}
>
<div
className={router.locale === "en" ? "option active" : "option"}
>
<input
className="form-check-input"
type="radio"
name="flexRadioDefault"
checked={router.locale === "en"}
/>
<span className="price">{t("english")}</span>
</div>
</Link>
<Link
className="nolink"
href="/more"
locale="de"
onClick={() => handleLanguageChange("de")}
>
<div
className={router.locale === "de" ? "option active" : "option"}
>
<input
className="form-check-input"
type="radio"
name="flexRadioDefault"
checked={router.locale === "de"}
/>
<span className="price">{t("german")}</span>
</div>
</Link>
<Link
className="nolink"
href="/more"
locale="es"
onClick={() => handleLanguageChange("es")}
>
<div
className={router.locale === "es" ? "option active" : "option"}
>
<input
className="form-check-input"
type="radio"
name="flexRadioDefault"
checked={router.locale === "es"}
/>
<span className="price">{t("spanish")}</span>
</div>
</Link>
<Link
className="nolink"
href="/more"
locale="fr"
onClick={() => handleLanguageChange("fr")}
>
<div
className={router.locale === "fr" ? "option active" : "option"}
>
<input
className="form-check-input"
type="radio"
name="flexRadioDefault"
checked={router.locale === "fr"}
/>
<span className="price">{t("french")}</span>
</div>
</Link>
<Link
className="nolink"
href="/more"
locale="pl"
onClick={() => handleLanguageChange("pl")}
>
<div
className={router.locale === "pl" ? "option active" : "option"}
>
<input
className="form-check-input"
type="radio"
name="flexRadioDefault"
checked={router.locale === "pl"}
/>
<span className="price">{t("polish")}</span>
</div>
</Link>
<Link
className="nolink"
href="/more"
locale="cz"
onClick={() => handleLanguageChange("cz")}
>
<div
className={router.locale === "cz" ? "option active" : "option"}
>
<input
className="form-check-input"
type="radio"
name="flexRadioDefault"
checked={router.locale === "cz"}
/>
<span className="price">{t("czech")}</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>
</div>
<OLEDMode />
</Container>
</>
);
}
export async function getStaticProps({ locale }: GetStaticPropsContext) {
return {
props: {
messages: (await import(`../locales/${locale}.json`)).default,
},
};
}

View file

@ -1,40 +0,0 @@
import { GetStaticPropsContext } from 'next'
import { useTranslations } from 'next-intl';
import React, { useState, useEffect } from 'react';
import Container from "@/components/elements/container";
import Nav from "@/components/nav";
const PrivacyPolicy = () => {
const t = useTranslations('Privacy');
const [datenschutz, setDatenschutz] = useState('');
useEffect(() => {
fetch('https://philipbrembeck.com/datenschutz.txt')
.then(response => response.text())
.then(text => setDatenschutz(text))
.catch(error => console.error(error));
}, []);
return (
<>
<Nav />
<Container>
<p className="small">
{t('germanonly')}
</p>
<div className="privacy" dangerouslySetInnerHTML={{ __html: datenschutz }} />
</Container>
</>
);
};
export default PrivacyPolicy;
export async function getStaticProps({ locale }: GetStaticPropsContext) {
return {
props: {
messages: (await import(`../locales/${locale}.json`)).default
}
};
}

View file

@ -1,26 +0,0 @@
import { GetStaticPropsContext } from "next";
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") }} />
</Container>
</>
);
}
export async function getStaticProps({ locale }: GetStaticPropsContext) {
return {
props: {
messages: (await import(`../locales/${locale}.json`)).default,
},
};
}

View file

@ -1,13 +1,14 @@
@use 'sass:math';
/* Setup: @include font-size(50px); and for media-queries @include mq(tablet){} */
// converts several units to em (supports px, pt, pc, in, mm, cm)
@function em($size) {
@if not unitless($size) {
@if unit($size) == em {
@if unit($size)==em {
@return $size;
}
@return ((0px + $size) / 16px * 1em);
@return math.div(0px + $size, 16px) * 1em;
}
@return null;
@ -18,7 +19,7 @@
$size: em($size);
@if $size {
@return em($size) / 1em + rem;
@return calc(em($size) / 1em)+rem;
}
@return null;
@ -32,19 +33,25 @@
// MEDIA QUERY Mixin
@mixin mq($size) {
@if $size == phone {
@if $size ==phone {
@media (max-width: 48rem) {
@content;
}
} @else if $size == s-phone {
}
@else if $size ==s-phone {
@media (max-width: 21.5rem) {
@content;
}
} @else if $size == s-desktop {
}
@else if $size ==s-desktop {
@media (max-width: 69.5rem) {
@content;
}
} @else if $size == desktop {
}
@else if $size ==desktop {
@media (min-width: 69.6rem) {
@content;
}
@ -53,14 +60,15 @@
// COLORSCHEME Mixin
@mixin theme($color) {
@if $color == light {
@media (prefers-color-scheme: light) {
@content;
}
@if $color ==light {
@media (prefers-color-scheme: light) {
@content;
}
@else if $color == dark {
@media (prefers-color-scheme: dark) {
@content;
}
}
@else if $color ==dark {
@media (prefers-color-scheme: dark) {
@content;
}
}
}

View file

@ -8,7 +8,7 @@
a {
font-weight: 400;
color: #ccc;
color: #505050;
text-decoration: underline;
&:hover,
@ -16,4 +16,4 @@
color: #000;
}
}
}
}

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;
}
}

32
src/tests/setup.ts Normal file
View file

@ -0,0 +1,32 @@
import "@testing-library/jest-dom";
global.console = {
...console,
log: jest.fn(),
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
};
jest.mock("next/navigation", () => ({
useRouter() {
return {
push: jest.fn(),
replace: jest.fn(),
prefetch: jest.fn(),
back: jest.fn(),
};
},
useSearchParams() {
return new URLSearchParams();
},
usePathname() {
return "";
},
}));
jest.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
useLocale: () => "en",
}));

View file

@ -1,20 +1,9 @@
import { test, expect } from "@playwright/test";
test("User should be able to load page and change language", async ({
page,
}) => {
await page.goto("http://localhost:3000/en");
await page.click("text=More");
await expect(page).toHaveURL("http://localhost:3000/more");
await page.click("text=Language");
await page.click("text=German");
await expect(page).toHaveURL("http://localhost:3000/de/more");
});
test("User should be able to input a barcode and get a result", async ({
page,
}) => {
await page.goto("http://localhost:3000/en");
await page.goto("https://staging.veganify.app/en");
const inputField = await page.$('input[name="barcode"]');
await inputField?.type("4066600204404");
@ -30,7 +19,7 @@ test("User should be able to input a barcode and get a result", async ({
test("User should be able to input ingredients and get a result", async ({
page,
}) => {
await page.goto("http://localhost:3000/en/ingredients");
await page.goto("https://staging.veganify.app/en/ingredients");
const inputField = await page.$('textarea[id="ingredients"]');
await inputField?.type("Duck");
@ -62,50 +51,12 @@ test("User should be able to input ingredients and get a result", async ({
});
});
test("User should be able to switch on OLED mode in darkmode, in lightmode, user should get an error", async ({
page,
}) => {
await page.goto("http://localhost:3000/en/more");
// Locate the switch input and click on it
const switchInput = await page.$("#oled-switch");
await switchInput?.click();
// Wait for either the "animated shake" class (error) or the background color to change
await Promise.any([
page.waitForSelector(".switch.animated.shake", { timeout: 5000 }),
page.waitForFunction(
() => {
const bodyStyles = window.getComputedStyle(document.body);
return bodyStyles.backgroundColor === "#000";
},
{ timeout: 5000 }
),
]);
// Check if the switch triggered the expected result
const bodyStyles = await page.evaluate(() => {
return window.getComputedStyle(document.body);
});
if (bodyStyles.backgroundColor === "#000") {
// Dark mode was activated
expect(bodyStyles.color).toBe("#141414");
} else {
// Light mode was activated
const switchClasses = await switchInput?.evaluate((input) =>
Array.from(input.classList)
);
expect(switchClasses).toEqual(
expect.arrayContaining(["animated", "shake"])
);
}
});
test("User should be able to input a barcode via the URL parameter `ean` ", async ({
page,
}) => {
await page.route("**/v0/product/*", (route) => {
expect(route.request().url()).toBe(
"https://api.veganify.app/v0/product/4066600204404"
expect(route.request().url()).toMatch(
/^https:\/\/(api|staging\.api)\.veganify\.app\/v0\/product\/4066600204404$/
);
expect(route.request().method()).toBe("POST");
route.fulfill({
@ -131,12 +82,9 @@ test("User should be able to input a barcode via the URL parameter `ean` ", asyn
});
});
await page.goto("http://localhost:3000/en?ean=4066600204404");
const inputField = await page.waitForSelector('input[name="barcode"]', {
visible: true,
});
const inputValue = await inputField.evaluate((el) => el.value);
await page.goto("https://staging.veganify.app/en?ean=4066600204404");
const inputField = await page.waitForSelector('input[name="barcode"]');
const inputValue = await inputField.inputValue();
expect(inputValue).toBe("4066600204404");
await page.waitForSelector(".loading_skeleton", { state: "hidden" });

View file

@ -17,8 +17,19 @@
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"plugins": [
{
"name": "next"
}
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "./types/**/*.d.ts"],
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
"./types/**/*.d.ts",
".next/types/**/*.ts"
],
"exclude": ["node_modules"]
}