mirror of
https://github.com/frontendnetwork/vegancheck.me
synced 2024-11-12 23:37:06 +00:00
Release (#846)
* 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:
parent
c166e98eeb
commit
eb75ccc5eb
91 changed files with 12303 additions and 12367 deletions
|
@ -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
|
||||
}
|
||||
}
|
||||
}]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
32
.github/workflows/build.yml
vendored
32
.github/workflows/build.yml
vendored
|
@ -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
|
||||
|
|
40
.github/workflows/pr.yml
vendored
40
.github/workflows/pr.yml
vendored
|
@ -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
1
.gitignore
vendored
|
@ -37,3 +37,4 @@ yarn-error.log*
|
|||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
test-results/.last-run.json
|
||||
|
|
6
.vscode/extensions.json
vendored
6
.vscode/extensions.json
vendored
|
@ -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
22
.vscode/settings.json
vendored
Normal 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
|
||||
}
|
|
@ -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
161
README.md
|
@ -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.
|
||||
|
|
|
@ -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
101
docs/index.html
101
docs/index.html
|
@ -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 "WE PLANT TREES." 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">© 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>
|
|
@ -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"><Link
|
||||
className="nolink"
|
||||
href="/more"
|
||||
locale="{yourlocale}"
|
||||
onClick={() => handleLanguageChange("{yourlocale}")}
|
||||
>
|
||||
<div
|
||||
className={router.locale === "{yourlocale}" ? "option active" : "option"}
|
||||
>
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="radio"
|
||||
name="flexRadioDefault"
|
||||
checked={router.locale === "{yourlocale}"}
|
||||
/>
|
||||
<span className="price">{t("translation_string_for_your_locale")}</span>
|
||||
</div>
|
||||
</Link></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">
|
||||
© 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
31
jest.config.ts
Normal 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);
|
|
@ -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
33
next.config.ts
Normal 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
9832
package-lock.json
generated
File diff suppressed because it is too large
Load diff
94
package.json
94
package.json
|
@ -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
5
playwright.config.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testMatch: ["**/*.spec.ts"],
|
||||
});
|
9101
pnpm-lock.yaml
Normal file
9101
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
1
project.inlang/.gitignore
vendored
Normal file
1
project.inlang/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
cache
|
18
public/img/ph_dark.svg
Normal file
18
public/img/ph_dark.svg
Normal file
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="250" height="54" viewBox="0 0 250 54" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g transform="translate(-130.000000, -73.000000)">
|
||||
<g transform="translate(130.000000, 73.000000)">
|
||||
<rect stroke="#221D21" stroke-width="1" fill="#221D21" x="0.5" y="0.5" width="249" height="53" rx="10"></rect>
|
||||
<text font-family="Helvetica-Bold, Helvetica" font-size="9" font-weight="bold" fill="#EEF2FF">
|
||||
<tspan x="53" y="20">#1 PRODUCT OF THE WEEK</tspan>
|
||||
</text>
|
||||
<text font-family="Helvetica-Bold, Helvetica" font-size="16" font-weight="bold" fill="#EEF2FF">
|
||||
<tspan x="52" y="40">Health & Fitness</tspan>
|
||||
</text>
|
||||
false
|
||||
<g transform="translate(11.000000, 12.000000)"><path d="M31,15.5 C31,24.0603917 24.0603917,31 15.5,31 C6.93960833,31 0,24.0603917 0,15.5 C0,6.93960833 6.93960833,0 15.5,0 C24.0603917,0 31,6.93960833 31,15.5" fill="#FFFFFF"></path><path d="M17.4329412,15.9558824 L17.4329412,15.9560115 L13.0929412,15.9560115 L13.0929412,11.3060115 L17.4329412,11.3060115 L17.4329412,11.3058824 C18.7018806,11.3058824 19.7305882,12.3468365 19.7305882,13.6308824 C19.7305882,14.9149282 18.7018806,15.9558824 17.4329412,15.9558824 M17.4329412,8.20588235 L17.4329412,8.20601152 L10.0294118,8.20588235 L10.0294118,23.7058824 L13.0929412,23.7058824 L13.0929412,19.0560115 L17.4329412,19.0560115 L17.4329412,19.0558824 C20.3938424,19.0558824 22.7941176,16.6270324 22.7941176,13.6308824 C22.7941176,10.6347324 20.3938424,8.20588235 17.4329412,8.20588235" fill="#221D21"></path></g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.7 KiB |
18
public/img/ph_light.svg
Normal file
18
public/img/ph_light.svg
Normal file
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="250" height="54" viewBox="0 0 250 54" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g transform="translate(-130.000000, -73.000000)">
|
||||
<g transform="translate(130.000000, 73.000000)">
|
||||
<rect stroke="#FF6154" stroke-width="1" fill="#FFFFFF" x="0.5" y="0.5" width="249" height="53" rx="10"></rect>
|
||||
<text font-family="Helvetica-Bold, Helvetica" font-size="9" font-weight="bold" fill="#FF6154">
|
||||
<tspan x="53" y="20">#1 PRODUCT OF THE WEEK</tspan>
|
||||
</text>
|
||||
<text font-family="Helvetica-Bold, Helvetica" font-size="16" font-weight="bold" fill="#FF6154">
|
||||
<tspan x="52" y="40">Health & Fitness</tspan>
|
||||
</text>
|
||||
false
|
||||
<g transform="translate(11.000000, 12.000000)"><path d="M31,15.5 C31,24.0603917 24.0603917,31 15.5,31 C6.93960833,31 0,24.0603917 0,15.5 C0,6.93960833 6.93960833,0 15.5,0 C24.0603917,0 31,6.93960833 31,15.5" fill="#FF6154"></path><path d="M17.4329412,15.9558824 L17.4329412,15.9560115 L13.0929412,15.9560115 L13.0929412,11.3060115 L17.4329412,11.3060115 L17.4329412,11.3058824 C18.7018806,11.3058824 19.7305882,12.3468365 19.7305882,13.6308824 C19.7305882,14.9149282 18.7018806,15.9558824 17.4329412,15.9558824 M17.4329412,8.20588235 L17.4329412,8.20601152 L10.0294118,8.20588235 L10.0294118,23.7058824 L13.0929412,23.7058824 L13.0929412,19.0560115 L17.4329412,19.0560115 L17.4329412,19.0558824 C20.3938424,19.0558824 22.7941176,16.6270324 22.7941176,13.6308824 C22.7941176,10.6347324 20.3938424,8.20588235 17.4329412,8.20588235" fill="#FFFFFF"></path></g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.7 KiB |
18
public/img/ph_neutral.svg
Normal file
18
public/img/ph_neutral.svg
Normal file
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="250" height="54" viewBox="0 0 250 54" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g transform="translate(-130.000000, -73.000000)">
|
||||
<g transform="translate(130.000000, 73.000000)">
|
||||
<rect stroke="#D9E1EC" stroke-width="1" fill="#FFFFFF" x="0.5" y="0.5" width="249" height="53" rx="10"></rect>
|
||||
<text font-family="Helvetica-Bold, Helvetica" font-size="9" font-weight="bold" fill="#4B587C">
|
||||
<tspan x="53" y="20">#1 PRODUCT OF THE WEEK</tspan>
|
||||
</text>
|
||||
<text font-family="Helvetica-Bold, Helvetica" font-size="16" font-weight="bold" fill="#4B587C">
|
||||
<tspan x="52" y="40">Health & Fitness</tspan>
|
||||
</text>
|
||||
false
|
||||
<g transform="translate(11.000000, 12.000000)"><path d="M31,15.5 C31,24.0603917 24.0603917,31 15.5,31 C6.93960833,31 0,24.0603917 0,15.5 C0,6.93960833 6.93960833,0 15.5,0 C24.0603917,0 31,6.93960833 31,15.5" fill="#4B587C"></path><path d="M17.4329412,15.9558824 L17.4329412,15.9560115 L13.0929412,15.9560115 L13.0929412,11.3060115 L17.4329412,11.3060115 L17.4329412,11.3058824 C18.7018806,11.3058824 19.7305882,12.3468365 19.7305882,13.6308824 C19.7305882,14.9149282 18.7018806,15.9558824 17.4329412,15.9558824 M17.4329412,8.20588235 L17.4329412,8.20601152 L10.0294118,8.20588235 L10.0294118,23.7058824 L13.0929412,23.7058824 L13.0929412,19.0560115 L17.4329412,19.0560115 L17.4329412,19.0558824 C20.3938424,19.0558824 22.7941176,16.6270324 22.7941176,13.6308824 C22.7941176,10.6347324 20.3938424,8.20588235 17.4329412,8.20588235" fill="#FFFFFF"></path></g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.7 KiB |
28
src/app/[locale]/impressum/page.tsx
Normal file
28
src/app/[locale]/impressum/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
10
src/app/[locale]/ingredients/page.tsx
Normal file
10
src/app/[locale]/ingredients/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
71
src/app/[locale]/layout.tsx
Normal file
71
src/app/[locale]/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
178
src/app/[locale]/more/page.tsx
Normal file
178
src/app/[locale]/more/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
31
src/app/[locale]/privacy-policy/page.tsx
Normal file
31
src/app/[locale]/privacy-policy/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
13
src/app/[locale]/tos/page.tsx
Normal file
13
src/app/[locale]/tos/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
53
src/components/Check/LoadingSkeleton.tsx
Normal file
53
src/components/Check/LoadingSkeleton.tsx
Normal file
|
@ -0,0 +1,53 @@
|
|||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export function LoadingSkeleton() {
|
||||
const t = useTranslations("Check");
|
||||
|
||||
return (
|
||||
<div id="result" className="loading_skeleton">
|
||||
<div className="animated fadeIn resultborder" id="RSFound">
|
||||
<span className="unknown">
|
||||
<span className="name skeleton"> </span>
|
||||
</span>
|
||||
<span id="result_sh">
|
||||
<div className="Grid">
|
||||
<div className="Grid-cell description skeleton">{t("vegan")}</div>
|
||||
<div className="Grid-cell icons skeleton">
|
||||
<span className="icon-help"></span>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
<div className="Grid">
|
||||
<div className="Grid-cell description skeleton">
|
||||
{t("vegetarian")}
|
||||
</div>
|
||||
<div className="Grid-cell icons skeleton">
|
||||
<span className="icon-help"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="Grid">
|
||||
<div className="Grid-cell description skeleton">{t("palmoil")}</div>
|
||||
<div className="Grid-cell icons skeleton">
|
||||
<span className="icon-help"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="Grid">
|
||||
<div className="Grid-cell description skeleton">Nutriscore</div>
|
||||
<div className="Grid-cell icons skeleton">
|
||||
<span className="icon-help"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="Grid">
|
||||
<div className="Grid-cell description skeleton">Grade</div>
|
||||
<div className="Grid-cell icons skeleton">
|
||||
<span className="icon-help"></span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="source skeleton"> </span>
|
||||
<span className="button skeleton">{t("share")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
167
src/components/Check/ProductResult.tsx
Normal file
167
src/components/Check/ProductResult.tsx
Normal 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>
|
||||
);
|
||||
}
|
21
src/components/Check/ResultGrid.tsx
Normal file
21
src/components/Check/ResultGrid.tsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { ReactNode } from "react";
|
||||
|
||||
interface ResultGridProps {
|
||||
label: string;
|
||||
iconClass: string;
|
||||
helpModal?: ReactNode;
|
||||
}
|
||||
|
||||
export function ResultGrid({ label, iconClass, helpModal }: ResultGridProps) {
|
||||
return (
|
||||
<div className="Grid">
|
||||
<div className="Grid-cell description">
|
||||
{label}
|
||||
{helpModal}
|
||||
</div>
|
||||
<div className="Grid-cell icons">
|
||||
<span className={iconClass}></span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
60
src/components/Check/SearchForm.tsx
Normal file
60
src/components/Check/SearchForm.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
73
src/components/Check/StatusMessages.tsx
Normal file
73
src/components/Check/StatusMessages.tsx
Normal file
|
@ -0,0 +1,73 @@
|
|||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface StatusMessagesProps {
|
||||
showNotFound: boolean;
|
||||
showInvalid: boolean;
|
||||
showTimeout: boolean;
|
||||
showTimeoutFinal: boolean;
|
||||
}
|
||||
|
||||
export function StatusMessages({
|
||||
showNotFound,
|
||||
showInvalid,
|
||||
showTimeout,
|
||||
showTimeoutFinal,
|
||||
}: StatusMessagesProps) {
|
||||
const t = useTranslations("Check");
|
||||
|
||||
if (showNotFound) {
|
||||
return (
|
||||
<div id="result">
|
||||
<div className="resultborder" id="RSNotFound">
|
||||
<span>{t("notindb")}</span>
|
||||
<p className="missing" style={{ textAlign: "center" }}>
|
||||
{t("notindb_add")}{" "}
|
||||
<a
|
||||
href="https://world.openfoodfacts.org/cgi/product.pl"
|
||||
target="_blank"
|
||||
>
|
||||
{t("add_food")}
|
||||
</a>{" "}
|
||||
{t("or")}{" "}
|
||||
<a
|
||||
href="https://world.openbeautyfacts.org/cgi/product.pl"
|
||||
target="_blank"
|
||||
>
|
||||
{t("add_cosmetic")}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (showInvalid) {
|
||||
return (
|
||||
<div id="result">
|
||||
<div className="resultborder" id="RSInvalid">
|
||||
<span>{t("wrongbarcode")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (showTimeout) {
|
||||
return (
|
||||
<div className="timeout animated fadeIn">
|
||||
{t("timeout1")}
|
||||
<span>.</span>
|
||||
<span>.</span>
|
||||
<span>.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (showTimeoutFinal) {
|
||||
return <div className="timeout-final animated fadeIn">{t("timeout2")}</div>;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
114
src/components/Check/index.tsx
Normal file
114
src/components/Check/index.tsx
Normal file
|
@ -0,0 +1,114 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, 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 />}
|
||||
</>
|
||||
);
|
||||
}
|
13
src/components/Check/models/product.ts
Normal file
13
src/components/Check/models/product.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
export interface NutriscoreGrade {
|
||||
score: string;
|
||||
className: string;
|
||||
}
|
||||
|
||||
export interface ProductState {
|
||||
vegan: string;
|
||||
vegetarian: string;
|
||||
animaltestfree: string;
|
||||
palmoil: string;
|
||||
nutriscore: NutriscoreGrade;
|
||||
grade: NutriscoreGrade;
|
||||
}
|
216
src/components/Check/utils/product-actions.test.ts
Normal file
216
src/components/Check/utils/product-actions.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
34
src/components/Check/utils/product-actions.ts
Normal file
34
src/components/Check/utils/product-actions.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
"use server";
|
||||
"use cache";
|
||||
|
||||
import Veganify from "@frontendnetwork/veganify";
|
||||
|
||||
import { ProductResult } from "@/models/ProductResults";
|
||||
import { Sources } from "@/models/Sources";
|
||||
|
||||
export async function fetchProduct(barcode: string): Promise<{
|
||||
product?: ProductResult;
|
||||
sources?: Sources;
|
||||
status: number;
|
||||
}> {
|
||||
try {
|
||||
const data = await Veganify.getProductByBarcode(
|
||||
barcode,
|
||||
process.env.NEXT_PUBLIC_STAGING === "true"
|
||||
);
|
||||
if ("product" in data && "sources" in data) {
|
||||
return {
|
||||
product: data.product,
|
||||
sources: data.sources,
|
||||
status: data.status,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
status: data.status,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Product fetch error:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
157
src/components/Check/utils/product-helpers.test.ts
Normal file
157
src/components/Check/utils/product-helpers.test.ts
Normal 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: "" },
|
||||
});
|
||||
});
|
||||
});
|
34
src/components/Check/utils/product-helpers.ts
Normal file
34
src/components/Check/utils/product-helpers.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
import { ProductResult } from "@/models/ProductResults";
|
||||
|
||||
import { NutriscoreGrade, ProductState } from "../models/product";
|
||||
|
||||
export function getProductState(result: ProductResult): ProductState {
|
||||
const getVeganState = (value: boolean | "n/a" | undefined): string => {
|
||||
if (value === true) return "vegan icon-ok";
|
||||
if (value === false) return "non-vegan icon-cancel";
|
||||
return "unknown icon-help";
|
||||
};
|
||||
|
||||
const getNutriscoreClass = (score: string | undefined): NutriscoreGrade => {
|
||||
if (!score || score === "n/a")
|
||||
return { score: "unknown icon-help", className: "" };
|
||||
|
||||
const normalizedScore = score.toLowerCase();
|
||||
if (["a", "b", "c", "d", "e"].includes(normalizedScore)) {
|
||||
return {
|
||||
score: `nutri_${normalizedScore} icon-${normalizedScore}`,
|
||||
className: `nutri_${normalizedScore}`,
|
||||
};
|
||||
}
|
||||
return { score: "unknown icon-help", className: "" };
|
||||
};
|
||||
|
||||
return {
|
||||
vegan: getVeganState(result.vegan),
|
||||
vegetarian: getVeganState(result.vegetarian),
|
||||
animaltestfree: getVeganState(result.animaltestfree),
|
||||
palmoil: getVeganState(result.palmoil),
|
||||
nutriscore: getNutriscoreClass(result.nutriscore),
|
||||
grade: getNutriscoreClass(result.grade),
|
||||
};
|
||||
}
|
110
src/components/IngredientsCheck/IngredientsForm.tsx
Normal file
110
src/components/IngredientsCheck/IngredientsForm.tsx
Normal 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"> </span>
|
||||
<span className="source skeleton"> </span>
|
||||
<span className="source skeleton"> </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
24
src/components/IngredientsCheck/IngredientsList.tsx
Normal file
24
src/components/IngredientsCheck/IngredientsList.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import React from "react";
|
||||
|
||||
export function IngredientList({
|
||||
items,
|
||||
iconClass,
|
||||
}: {
|
||||
items: string[];
|
||||
iconClass: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{items.map((item) => (
|
||||
<div className="Grid" key={item}>
|
||||
<div className="Grid-cell description">
|
||||
{item.charAt(0).toUpperCase() + item.slice(1)}
|
||||
</div>
|
||||
<div className="Grid-cell icons">
|
||||
<span className={iconClass}></span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
45
src/components/IngredientsCheck/ResultsDisplay.tsx
Normal file
45
src/components/IngredientsCheck/ResultsDisplay.tsx
Normal file
|
@ -0,0 +1,45 @@
|
|||
import { IngredientList } from "./IngredientsList";
|
||||
import { IngredientResult } from "./models/IngredientResult";
|
||||
import { SourceInfo } from "./SourceInfo";
|
||||
|
||||
export function ResultDisplay({
|
||||
result,
|
||||
t,
|
||||
}: {
|
||||
result: IngredientResult;
|
||||
t: (key: string, values?: Record<string, string>) => string;
|
||||
}) {
|
||||
return (
|
||||
<div id="result">
|
||||
<div className="">
|
||||
<div className="resultborder">
|
||||
<div className="Grid">
|
||||
<div className="Grid-cell description">
|
||||
<b>{t("vegan")}</b>
|
||||
</div>
|
||||
<div className="Grid-cell icons">
|
||||
<span
|
||||
className={
|
||||
result.vegan ? "vegan icon-ok" : "non-vegan icon-cancel"
|
||||
}
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
<IngredientList
|
||||
items={result.notVegan}
|
||||
iconClass="non-vegan icon-cancel"
|
||||
/>
|
||||
<IngredientList
|
||||
items={result.maybeVegan}
|
||||
iconClass="maybe-vegan icon-help"
|
||||
/>
|
||||
<IngredientList
|
||||
items={result.surelyVegan}
|
||||
iconClass="vegan icon-ok"
|
||||
/>
|
||||
<SourceInfo t={t} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
44
src/components/IngredientsCheck/SourceInfo.tsx
Normal file
44
src/components/IngredientsCheck/SourceInfo.tsx
Normal 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>{" "}
|
||||
&{" "}
|
||||
<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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
export interface IngredientResult {
|
||||
vegan: boolean | null;
|
||||
surelyVegan: string[];
|
||||
notVegan: string[];
|
||||
maybeVegan: string[];
|
||||
}
|
136
src/components/IngredientsCheck/utils/actions.test.ts
Normal file
136
src/components/IngredientsCheck/utils/actions.test.ts
Normal 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"
|
||||
);
|
||||
});
|
||||
});
|
27
src/components/IngredientsCheck/utils/actions.ts
Normal file
27
src/components/IngredientsCheck/utils/actions.ts
Normal 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`);
|
||||
}
|
||||
}
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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
|
||||
)
|
||||
);
|
||||
}
|
41
src/components/Scanner/ScanButton.tsx
Normal file
41
src/components/Scanner/ScanButton.tsx
Normal file
|
@ -0,0 +1,41 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { DetectionResult } from "./models/scanner";
|
||||
import { ViewportScanner } from "./ViewportScanner";
|
||||
|
||||
interface ScanButtonProps {
|
||||
onDetected: (barcode: string) => void;
|
||||
handleSubmit: (barcode: string, obj: object) => void;
|
||||
}
|
||||
|
||||
export function ScanButton({ onDetected, handleSubmit }: ScanButtonProps) {
|
||||
const [scanning, setScanning] = useState(false);
|
||||
|
||||
const handleDetection = (result: DetectionResult) => {
|
||||
const barcode = result.codeResult.code;
|
||||
setScanning(false);
|
||||
onDetected(barcode);
|
||||
handleSubmit(barcode, {});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Barcode scannen"
|
||||
onClick={() => setScanning(true)}
|
||||
>
|
||||
<span className="icon-barcode" />
|
||||
</button>
|
||||
|
||||
{scanning && (
|
||||
<ViewportScanner
|
||||
onDetected={handleDetection}
|
||||
setScanning={setScanning}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
145
src/components/Scanner/ViewportScanner.tsx
Normal file
145
src/components/Scanner/ViewportScanner.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
1
src/components/Scanner/index.ts
Normal file
1
src/components/Scanner/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { ScanButton as default } from "./ScanButton";
|
10
src/components/Scanner/models/scanner.ts
Normal file
10
src/components/Scanner/models/scanner.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
export interface ScannerProps {
|
||||
onDetected: (result: DetectionResult) => void;
|
||||
setScanning: (scanning: boolean) => void;
|
||||
}
|
||||
|
||||
export interface DetectionResult {
|
||||
codeResult: {
|
||||
code: string;
|
||||
};
|
||||
}
|
|
@ -1,218 +0,0 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
import Quagga from "@ericblade/quagga2";
|
||||
import React, { Component } from "react";
|
||||
interface ScannerProps {
|
||||
onDetected: (result: any) => void;
|
||||
setScanning: (scanning: boolean) => void;
|
||||
}
|
||||
|
||||
interface ScannerState {
|
||||
facingMode: string;
|
||||
isHidden: boolean;
|
||||
isMirrored: boolean;
|
||||
}
|
||||
|
||||
class Scanner extends Component<ScannerProps, ScannerState> {
|
||||
state: ScannerState = {
|
||||
facingMode: "user",
|
||||
isHidden: false,
|
||||
isMirrored: true,
|
||||
};
|
||||
|
||||
handleClick = () => {
|
||||
const { facingMode } = this.state;
|
||||
const newFacingMode = facingMode === "environment" ? "user" : "environment";
|
||||
const isMirrored = newFacingMode === "user";
|
||||
this.setState({ facingMode: newFacingMode, isMirrored });
|
||||
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
|
||||
Quagga.init(
|
||||
{
|
||||
inputStream: {
|
||||
type: "LiveStream",
|
||||
constraints: {
|
||||
aspectRatio: { ideal: height / width },
|
||||
facingMode: newFacingMode,
|
||||
height: { min: 480, ideal: height, max: 1080 },
|
||||
},
|
||||
},
|
||||
locator: {
|
||||
patchSize: "medium",
|
||||
halfSample: true,
|
||||
},
|
||||
numOfWorkers: 2,
|
||||
decoder: {
|
||||
readers: [
|
||||
"ean_reader",
|
||||
"code_39_reader",
|
||||
"code_128_reader",
|
||||
"i2of5_reader",
|
||||
],
|
||||
},
|
||||
locate: true,
|
||||
},
|
||||
(err: any) => {
|
||||
if (err) {
|
||||
return console.log(err);
|
||||
}
|
||||
Quagga.start();
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
const { facingMode } = this.state;
|
||||
console.log(facingMode);
|
||||
this.handleClick();
|
||||
Quagga.onDetected(this._onDetected);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
Quagga.offDetected(this._onDetected);
|
||||
}
|
||||
|
||||
_onDetected = (result: any) => {
|
||||
const { onDetected } = this.props;
|
||||
onDetected(result);
|
||||
Quagga.stop();
|
||||
};
|
||||
|
||||
_onClose = () => {
|
||||
const { isHidden } = this.state;
|
||||
this.setState({ isHidden: !isHidden });
|
||||
const { setScanning } = this.props;
|
||||
setScanning(false);
|
||||
Quagga.stop();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { isHidden, isMirrored } = this.state;
|
||||
|
||||
const vid: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
zIndex: 999,
|
||||
left: "50%",
|
||||
top: 0,
|
||||
transform: isMirrored ? "translateX(-50%) scaleX(-1)" : "translateX(-50%)",
|
||||
};
|
||||
|
||||
const backdrop: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
zIndex: 998,
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
background: "rgba(0, 0, 0, 0.2)",
|
||||
backdropFilter: "blur(0.5rem)",
|
||||
WebkitBackdropFilter: "blur(0.5rem)",
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
!isHidden && (
|
||||
<>
|
||||
<div style={backdrop}></div>
|
||||
<div id="controls">
|
||||
<span id="close">
|
||||
<div className="flex-container">
|
||||
<div className="flex-item">
|
||||
<span
|
||||
id="closebtn"
|
||||
className="icon-left-open"
|
||||
onClick={this._onClose}
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<div className="flex-item">
|
||||
<span
|
||||
id="switch-camera"
|
||||
className="icon-flipcamera"
|
||||
onClick={this.handleClick}
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<div id="interactive" className="viewport" style={vid}></div>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface ResultProps {
|
||||
result: any;
|
||||
}
|
||||
|
||||
class Result extends Component<ResultProps> {
|
||||
render() {
|
||||
const { result } = this.props;
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
return result.codeResult.code;
|
||||
}
|
||||
}
|
||||
|
||||
interface ScanProps {
|
||||
onDetected: (barcode: string) => void;
|
||||
handleSubmit: (barcode: string, obj: object) => void;
|
||||
}
|
||||
|
||||
interface ScanState {
|
||||
scanning: boolean;
|
||||
results: any[];
|
||||
codeResult: string;
|
||||
}
|
||||
|
||||
class Scan extends React.Component<ScanProps, ScanState> {
|
||||
state: ScanState = {
|
||||
scanning: false,
|
||||
results: [],
|
||||
codeResult: "",
|
||||
};
|
||||
|
||||
_scan = () => {
|
||||
this.setState({ scanning: true });
|
||||
};
|
||||
|
||||
_onDetected = (result: any) => {
|
||||
const { onDetected, handleSubmit } = this.props;
|
||||
const { results } = this.state;
|
||||
const newResults = [...results, result];
|
||||
const codeResult = result.codeResult.code;
|
||||
this.setState({ results: newResults, codeResult, scanning: false });
|
||||
onDetected(codeResult);
|
||||
Quagga.stop();
|
||||
handleSubmit(codeResult, {});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { scanning } = this.state;
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Barcode scannen"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={this._scan}
|
||||
>
|
||||
<span className="icon-barcode" />
|
||||
</button>
|
||||
{scanning ? (
|
||||
<Scanner
|
||||
onDetected={this._onDetected}
|
||||
setScanning={(scanning) => this.setState({ scanning })}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Scan;
|
|
@ -1,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;
|
||||
|
|
|
@ -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>
|
||||
© OpenFoodFacts Contributors, licensed under{" "}
|
||||
<a href="https://opendatacommons.org/licenses/odbl/1.0/">
|
||||
Open Database License
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a href="https://opendatacommons.org/licenses/dbcl/1.0/">
|
||||
Database Contents License
|
||||
</a>
|
||||
.<br />
|
||||
© Open EAN/GTIN Database Contributors, licensed under{" "}
|
||||
<a href="https://www.gnu.org/licenses/fdl-1.3.html">
|
||||
GNU FDL
|
||||
</a>
|
||||
.<br />
|
||||
© Veganify Contributors and Hamed Montazeri, licensed
|
||||
under{" "}
|
||||
<a href="https://github.com/JokeNetwork/vegan-ingredients-api/blob/master/LICENSE">
|
||||
MIT License
|
||||
</a>
|
||||
, sourced from{" "}
|
||||
<a href="https://www.veganpeace.com/ingredients/ingredients.htm">
|
||||
VeganPeace
|
||||
</a>
|
||||
,{" "}
|
||||
<a href="https://www.peta.org/living/food/animal-ingredients-list/">
|
||||
PETA
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a href="https://www.veganwolf.com/animal_ingredients.htm">
|
||||
The VEGAN WOLF
|
||||
</a>
|
||||
.<br />
|
||||
© Veganify Contributors, sourced from ©{" "}
|
||||
<a href="https://crueltyfree.peta.org">
|
||||
PETA (Beauty without Bunnies)
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</ModalWrapper>
|
||||
</span>
|
||||
<ShareButton productName={productname} barcode={barcode} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{showNotFound && (
|
||||
<div id="result">
|
||||
<div className="resultborder" id="RSNotFound">
|
||||
<span>{t("notindb")}</span>
|
||||
<p className="missing" style={{ textAlign: "center" }}>
|
||||
{t("notindb_add")}{" "}
|
||||
<a
|
||||
href="https://world.openfoodfacts.org/cgi/product.pl"
|
||||
target="_blank"
|
||||
>
|
||||
{t("add_food")}
|
||||
</a>{" "}
|
||||
{t("or")}{" "}
|
||||
<a
|
||||
href="https://world.openbeautyfacts.org/cgi/product.pl"
|
||||
target="_blank"
|
||||
>
|
||||
{t("add_cosmetic")}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showInvalid && (
|
||||
<div id="result">
|
||||
<div className="resultborder" id="RSInvalid">
|
||||
<span>{t("wrongbarcode")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showTimeout && (
|
||||
<div className="timeout animated fadeIn">
|
||||
{t("timeout1")}
|
||||
<span>.</span>
|
||||
<span>.</span>
|
||||
<span>.</span>
|
||||
</div>
|
||||
)}
|
||||
{showTimeoutFinal && (
|
||||
<div className="timeout-final animated fadeIn">{t("timeout2")}</div>
|
||||
)}
|
||||
{loading && (
|
||||
<>
|
||||
<div id="result" className="loading_skeleton">
|
||||
<div className="animated fadeIn resultborder" id="RSFound">
|
||||
<span className="unknown">
|
||||
<span className="name skeleton"> </span>
|
||||
</span>
|
||||
<span id="result_sh">
|
||||
<div className="Grid">
|
||||
<div className="Grid-cell description skeleton">
|
||||
{t("vegan")}
|
||||
</div>
|
||||
<div className="Grid-cell icons skeleton">
|
||||
<span className="icon-help"></span>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
<div className="Grid">
|
||||
<div className="Grid-cell description skeleton">
|
||||
{t("vegetarian")}
|
||||
</div>
|
||||
<div className="Grid-cell icons skeleton">
|
||||
<span className="icon-help"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="Grid">
|
||||
<div className="Grid-cell description skeleton">
|
||||
{t("palmoil")}
|
||||
</div>
|
||||
<div className="Grid-cell icons skeleton">
|
||||
<span className="icon-help"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="Grid">
|
||||
<div className="Grid-cell description skeleton">Nutriscore</div>
|
||||
<div className="Grid-cell icons skeleton">
|
||||
<span className="icon-help"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="Grid">
|
||||
<div className="Grid-cell description skeleton">Grade</div>
|
||||
<div className="Grid-cell icons skeleton">
|
||||
<span className="icon-help"></span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="source skeleton"> </span>
|
||||
<span className="button skeleton">{t("share")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductSearch;
|
|
@ -1,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>
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>{" "}
|
||||
&{" "}
|
||||
<a href="https://www.veganwolf.com/animal_ingredients.htm">
|
||||
The VEGAN WOLF
|
||||
</a>
|
||||
<ModalWrapper
|
||||
id="license"
|
||||
buttonType="sup"
|
||||
buttonClass="help-icon"
|
||||
buttonText="?"
|
||||
>
|
||||
<span className="center">
|
||||
<Image
|
||||
src="../img/license_img.svg"
|
||||
className="heading_img"
|
||||
alt="Licenses"
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
<h1>{t("licenses")}</h1>
|
||||
</span>
|
||||
<p>{t("licenses_desc")}</p>
|
||||
<p>
|
||||
© OpenFoodFacts Contributors, licensed under{" "}
|
||||
<a href="https://opendatacommons.org/licenses/odbl/1.0/">
|
||||
Open Database License
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a href="https://opendatacommons.org/licenses/dbcl/1.0/">
|
||||
Database Contents License
|
||||
</a>
|
||||
.<br />
|
||||
© Open EAN/GTIN Database Contributors, licensed under{" "}
|
||||
<a href="https://www.gnu.org/licenses/fdl-1.3.html">
|
||||
GNU FDL
|
||||
</a>
|
||||
.<br />
|
||||
© Veganify Contributors and Hamed Montazeri, licensed
|
||||
under{" "}
|
||||
<a href="https://github.com/JokeNetwork/vegan-ingredients-api/blob/master/LICENSE">
|
||||
MIT License
|
||||
</a>
|
||||
, sourced from{" "}
|
||||
<a href="https://www.veganpeace.com/ingredients/ingredients.htm">
|
||||
VeganPeace
|
||||
</a>
|
||||
,{" "}
|
||||
<a href="https://www.peta.org/living/food/animal-ingredients-list/">
|
||||
PETA
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a href="https://www.veganwolf.com/animal_ingredients.htm">
|
||||
The VEGAN WOLF
|
||||
</a>
|
||||
.<br />
|
||||
© Veganify Contributors, sourced from ©{" "}
|
||||
<a href="https://crueltyfree.peta.org">
|
||||
PETA (Beauty without Bunnies)
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</ModalWrapper>
|
||||
<br />
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t("languagewarning", {
|
||||
deepl: '<a href="https://deepl.com">DeepL</a>',
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div id="result">
|
||||
<span className="animated fadeIn">
|
||||
<div className="resultborder">{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"> </span>
|
||||
<span className="source skeleton"> </span>
|
||||
<span className="source skeleton"> </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default IngredientsCheck;
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
58
src/components/shared/LicenseModalContent.tsx
Normal file
58
src/components/shared/LicenseModalContent.tsx
Normal 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>
|
||||
© OpenFoodFacts Contributors, licensed under{" "}
|
||||
<a href="https://opendatacommons.org/licenses/odbl/1.0/">
|
||||
Open Database License
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a href="https://opendatacommons.org/licenses/dbcl/1.0/">
|
||||
Database Contents License
|
||||
</a>
|
||||
.<br />
|
||||
© Open EAN/GTIN Database Contributors, licensed under{" "}
|
||||
<a href="https://www.gnu.org/licenses/fdl-1.3.html">GNU FDL</a>
|
||||
.<br />
|
||||
© Veganify Contributors and Hamed Montazeri, licensed under{" "}
|
||||
<a href="https://github.com/JokeNetwork/vegan-ingredients-api/blob/master/LICENSE">
|
||||
MIT License
|
||||
</a>
|
||||
, sourced from{" "}
|
||||
<a href="https://www.veganpeace.com/ingredients/ingredients.htm">
|
||||
VeganPeace
|
||||
</a>
|
||||
,{" "}
|
||||
<a href="https://www.peta.org/living/food/animal-ingredients-list/">
|
||||
PETA
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a href="https://www.veganwolf.com/animal_ingredients.htm">
|
||||
The VEGAN WOLF
|
||||
</a>
|
||||
.<br />
|
||||
© Veganify Contributors, sourced from ©{" "}
|
||||
<a href="https://crueltyfree.peta.org">PETA (Beauty without Bunnies)</a>
|
||||
.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LicenseModalContent;
|
|
@ -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
15
src/i18n/request.ts
Normal 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
10
src/i18n/routing.ts
Normal 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
12
src/middleware.ts
Normal 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*"],
|
||||
};
|
|
@ -1,5 +1,5 @@
|
|||
export type ErrorResponse = {
|
||||
export interface ErrorResponse {
|
||||
response: {
|
||||
status: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export type FlaggedItem = {
|
||||
export interface FlaggedItem {
|
||||
item: string;
|
||||
index: number;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
export type Sources = {
|
||||
export interface Sources {
|
||||
api?: string;
|
||||
baseuri?: string;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
a {
|
||||
font-weight: 400;
|
||||
color: #ccc;
|
||||
color: #505050;
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover,
|
||||
|
@ -16,4 +16,4 @@
|
|||
color: #000;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
32
src/tests/setup.ts
Normal 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",
|
||||
}));
|
|
@ -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" });
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue