├── .codeclimate.yml ├── .env.example ├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ ├── codeql.yml │ ├── lighthouse.yml │ ├── playwright.yml │ └── repomix.yml ├── .gitignore ├── .prettierrc ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── DOCS ├── PROJECT_REVIEW.md ├── SUGGESTIONS.md └── repository_context.txt ├── LICENSE ├── README.md ├── lighthouserc.json ├── next.config.js ├── package-lock.json ├── package.json ├── playwright.config.ts ├── postcss.config.js ├── public ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── favicon.svg └── images │ └── hero.jpg ├── renovate.json ├── screenshots └── screenshot1.jpg ├── src ├── components │ ├── AlgoliaSearch │ │ ├── AlgoliaSearchBox.component.tsx │ │ ├── MobileSearch.component.tsx │ │ └── SearchResults.component.tsx │ ├── Animations │ │ ├── FadeLeftToRight.component.tsx │ │ ├── FadeLeftToRightItem.component.tsx │ │ ├── FadeUp.component.tsx │ │ └── types │ │ │ └── Animations.types.ts │ ├── Cart │ │ ├── CartContents.component.tsx │ │ └── CartInitializer.component.tsx │ ├── Category │ │ └── Categories.component.tsx │ ├── Checkout │ │ ├── Billing.component.tsx │ │ └── CheckoutForm.component.tsx │ ├── Footer │ │ ├── Footer.component.tsx │ │ ├── Hamburger.component.tsx │ │ └── Stickynav.component.tsx │ ├── Header │ │ ├── Cart.component.tsx │ │ ├── Header.component.tsx │ │ └── Navbar.component.tsx │ ├── Index │ │ └── Hero.component.tsx │ ├── Input │ │ └── InputField.component.tsx │ ├── Layout │ │ ├── Layout.component.tsx │ │ └── PageTitle.component.tsx │ ├── LoadingSpinner │ │ └── LoadingSpinner.component.tsx │ ├── Product │ │ ├── AddToCart.component.tsx │ │ ├── DisplayProducts.component.tsx │ │ ├── ProductCard.component.tsx │ │ ├── ProductFilters.component.tsx │ │ ├── ProductList.component.tsx │ │ └── SingleProduct.component.tsx │ ├── SVG │ │ └── SVGMobileSearchIcon.component.tsx │ ├── UI │ │ ├── Button.component.tsx │ │ ├── Checkbox.component.tsx │ │ └── RangeSlider.component.tsx │ └── User │ │ └── UserRegistration.component.tsx ├── hooks │ └── useProductFilters.ts ├── images │ └── hero.jpg ├── pages │ ├── _app.tsx │ ├── _document.tsx │ ├── handlekurv.tsx │ ├── index.tsx │ ├── kasse.tsx │ ├── kategori │ │ └── [slug].tsx │ ├── kategorier.tsx │ ├── produkt │ │ └── [slug].tsx │ └── produkter.tsx ├── stores │ └── cartStore.ts ├── styles │ ├── algolia.min.css │ ├── animate.min.css │ └── globals.css ├── tests │ ├── Categories │ │ └── Categories.spec.ts │ └── Index │ │ └── Index.spec.ts ├── types │ └── product.ts └── utils │ ├── apollo │ └── ApolloClient.js │ ├── constants │ ├── INPUT_FIELDS.ts │ └── LINKS.ts │ ├── functions │ ├── functions.tsx │ └── productUtils.ts │ └── gql │ ├── GQL_MUTATIONS.ts │ └── GQL_QUERIES.ts ├── tailwind.config.js └── tsconfig.json /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | checks: 3 | argument-count: 4 | config: 5 | threshold: 4 6 | complex-logic: 7 | config: 8 | threshold: 4 9 | file-lines: 10 | config: 11 | threshold: 150 12 | method-complexity: 13 | config: 14 | threshold: 15 15 | method-count: 16 | config: 17 | threshold: 20 18 | method-lines: 19 | config: 20 | threshold: 150 21 | nested-control-flow: 22 | config: 23 | threshold: 4 24 | return-statements: 25 | config: 26 | threshold: 4 27 | similar-code: 28 | config: 29 | threshold: # language-specific defaults. an override will affect all languages. 30 | identical-code: 31 | config: 32 | threshold: # language-specific defaults. an override will affect all languages. 33 | exclude_patterns: 34 | - "config/" 35 | - "db/" 36 | - "dist/" 37 | - "features/" 38 | - "**/node_modules/" 39 | - "script/" 40 | - "**/spec/" 41 | - "**/test/" 42 | - "**/tests/" 43 | - "**/vendor/" 44 | - "**/*.d.ts" 45 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_GRAPHQL_URL="https://wordpress.url.com/graphql" 2 | NEXT_PUBLIC_ALGOLIA_INDEX_NAME= "algolia" 3 | NEXT_PUBLIC_PLACEHOLDER_SMALL_IMAGE_URL="https://res.cloudinary.com/placeholder-337_utsb7h.jpg" 4 | NEXT_PUBLIC_PLACEHOLDER_LARGE_IMAGE_URL="https://via.placeholder.com/600" 5 | NEXT_PUBLIC_ALGOLIA_APP_ID = "changeme" 6 | NEXT_PUBLIC_ALGOLIA_PUBLIC_API_KEY = "changeme" 7 | NODE_ENV="development" -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "eslint:recommended"], 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint"], 5 | "rules": { 6 | "@next/next/no-img-element": "off", 7 | "no-useless-escape": "off", 8 | "@typescript-eslint/no-unused-vars": 1 9 | }, 10 | "globals": { "JSX": true }, 11 | "env": { 12 | "browser": true, 13 | "es6": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | * * * 2 | 3 | name: Bug report 4 | about: Create a report to help us improve 5 | title: '' 6 | labels: '' 7 | assignees: '' 8 | 9 | * * * 10 | 11 | **Describe the bug** 12 | A clear and concise description of what the bug is. 13 | 14 | **To Reproduce** 15 | Steps to reproduce the behavior: 16 | 1. Go to '...' 17 | 2. Click on '....' 18 | 3. Scroll down to '....' 19 | 4. See error 20 | 21 | **Expected behavior** 22 | A clear and concise description of what you expected to happen. 23 | 24 | **Screenshots** 25 | If applicable, add screenshots to help explain your problem. 26 | 27 | **Desktop (please complete the following information):** 28 | 29 | - OS: [e.g. iOS] 30 | - Browser [e.g. chrome, safari] 31 | - Version [e.g. 22] 32 | 33 | **Smartphone (please complete the following information):** 34 | 35 | - Device: [e.g. iPhone6] 36 | - OS: [e.g. iOS8.1] 37 | - Browser [e.g. stock browser, safari] 38 | - Version [e.g. 22] 39 | 40 | **Additional context** 41 | Add any other context about the problem here. 42 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | schedule: 9 | - cron: "5 12 * * 2" 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ javascript ] 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v3 31 | with: 32 | languages: ${{ matrix.language }} 33 | queries: +security-and-quality 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v3 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v3 40 | with: 41 | category: "/language:${{ matrix.language }}" 42 | -------------------------------------------------------------------------------- /.github/workflows/lighthouse.yml: -------------------------------------------------------------------------------- 1 | name: Lighthouse CI 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | lighthouse: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | 10 | - name: Setup Node.js 11 | uses: actions/setup-node@v4 12 | with: 13 | node-version: '22' 14 | 15 | - name: Setup pnpm 16 | uses: pnpm/action-setup@v4 17 | with: 18 | version: 8 19 | run_install: false 20 | 21 | - name: Get pnpm store directory 22 | shell: bash 23 | run: | 24 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 25 | 26 | - name: Setup pnpm cache 27 | uses: actions/cache@v4 28 | with: 29 | path: ${{ env.STORE_PATH }} 30 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 31 | restore-keys: | 32 | ${{ runner.os }}-pnpm-store- 33 | 34 | - name: Install dependencies 35 | run: pnpm install 36 | 37 | - name: Build project 38 | env: 39 | NEXT_PUBLIC_GRAPHQL_URL: ${{ secrets.NEXT_PUBLIC_GRAPHQL_URL }} 40 | NEXT_PUBLIC_PLACEHOLDER_SMALL_IMAGE_URL: "https://via.placeholder.com/200" 41 | NEXT_PUBLIC_PLACEHOLDER_LARGE_IMAGE_URL: "https://via.placeholder.com/600" 42 | run: pnpm build 43 | 44 | - name: Start server 45 | env: 46 | NEXT_PUBLIC_GRAPHQL_URL: ${{ secrets.NEXT_PUBLIC_GRAPHQL_URL }} 47 | NEXT_PUBLIC_PLACEHOLDER_SMALL_IMAGE_URL: "https://via.placeholder.com/200" 48 | NEXT_PUBLIC_PLACEHOLDER_LARGE_IMAGE_URL: "https://via.placeholder.com/600" 49 | run: | 50 | pnpm start & 51 | echo "Waiting for server to be ready..." 52 | while ! nc -z localhost 3000; do 53 | sleep 5 54 | done 55 | # Additional wait to ensure full initialization 56 | sleep 20 57 | 58 | - name: Run Lighthouse CI 59 | run: | 60 | pnpm lhci:perf 61 | pnpm lhci:desktop 62 | env: 63 | LHCI_GITHUB_APP_TOKEN: ${{ secrets.GITHUB_TOKEN }} 64 | 65 | - name: Stop server 66 | if: always() 67 | run: | 68 | pkill -f "next start" || true 69 | -------------------------------------------------------------------------------- /.github/workflows/playwright.yml: -------------------------------------------------------------------------------- 1 | name: Playwright Tests 2 | on: 3 | push: 4 | branches: [ main, master ] 5 | pull_request: 6 | branches: [ main, master ] 7 | jobs: 8 | test: 9 | timeout-minutes: 60 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version: 20 16 | - name: Install dependencies 17 | run: npm ci 18 | - name: Install Playwright Browsers 19 | run: npx playwright install --with-deps 20 | - name: Build the project 21 | run: npm run build 22 | env: 23 | NEXT_PUBLIC_GRAPHQL_URL: ${{ secrets.NEXT_PUBLIC_GRAPHQL_URL }} 24 | - name: Start the application 25 | run: npm run start & 26 | env: 27 | NEXT_PUBLIC_GRAPHQL_URL: ${{ secrets.NEXT_PUBLIC_GRAPHQL_URL }} 28 | - name: Wait for the application to be ready 29 | run: | 30 | echo "Waiting for the application to be ready..." 31 | timeout 300 bash -c 'while [[ "$(curl -s -o /dev/null -w ''%{http_code}'' localhost:3000)" != "200" ]]; do sleep 5; done' || false 32 | echo "Application is ready!" 33 | - name: Run Playwright tests 34 | run: npx playwright test 35 | env: 36 | CI: true 37 | NEXT_PUBLIC_GRAPHQL_URL: ${{ secrets.NEXT_PUBLIC_GRAPHQL_URL }} 38 | DEBUG: pw:api 39 | - name: Upload test results 40 | if: always() 41 | uses: actions/upload-artifact@v4 42 | with: 43 | name: playwright-report 44 | path: playwright-report/ 45 | retention-days: 30 46 | - name: Upload test traces 47 | if: failure() 48 | uses: actions/upload-artifact@v4 49 | with: 50 | name: playwright-traces 51 | path: test-results/ 52 | retention-days: 30 53 | -------------------------------------------------------------------------------- /.github/workflows/repomix.yml: -------------------------------------------------------------------------------- 1 | name: Repository Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: # allows manual triggering 8 | 9 | permissions: 10 | contents: write 11 | pull-requests: write 12 | 13 | jobs: 14 | analyze: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 # fetch all history for better context 22 | 23 | - name: Wait for other checks 24 | run: | 25 | echo "Waiting for 5 minutes to allow other checks to complete..." 26 | sleep 300 27 | 28 | - name: Setup Node.js 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: "22" 32 | 33 | - name: Install Repomix 34 | run: npm install -g repomix 35 | 36 | - name: Generate Repository Documentation 37 | run: | 38 | echo "Creating DOCS directory..." 39 | mkdir -p DOCS 40 | 41 | echo "Running Repomix..." 42 | if ! repomix --output DOCS/repository_context.txt --style markdown --remove-empty-lines --verbose; then 43 | echo "Error: Repomix command failed" 44 | # Print directory contents for debugging 45 | echo "DOCS directory contents:" 46 | ls -la DOCS/ 47 | exit 1 48 | fi 49 | 50 | echo "Verifying output file..." 51 | if [ ! -f "DOCS/repository_context.txt" ]; then 52 | echo "Error: repository_context.txt was not created" 53 | # Print directory contents for debugging 54 | echo "DOCS directory contents:" 55 | ls -la DOCS/ 56 | exit 1 57 | fi 58 | 59 | if [ ! -s "DOCS/repository_context.txt" ]; then 60 | echo "Error: repository_context.txt is empty" 61 | exit 1 62 | fi 63 | 64 | echo "Repository context file generated successfully" 65 | echo "File size: $(stat --format=%s "DOCS/repository_context.txt") bytes" 66 | echo "First few lines of the file:" 67 | head -n 5 "DOCS/repository_context.txt" 68 | 69 | # Update Documentation 70 | - name: Commit and Push Changes 71 | run: | 72 | echo "Configuring git..." 73 | git config --local user.email "github-actions[bot]@users.noreply.github.com" 74 | git config --local user.name "github-actions[bot]" 75 | 76 | echo "Checking for changes..." 77 | if [[ -n "$(git status --porcelain)" ]]; then 78 | echo "Changes detected, committing..." 79 | 80 | # Stage only repository_context.txt to avoid unintended changes 81 | if ! git add DOCS/repository_context.txt; then 82 | echo "Error: Failed to stage repository_context.txt" 83 | exit 1 84 | fi 85 | 86 | if ! git commit -m "docs: update repository context via Repomix [skip ci]"; then 87 | echo "Error: Failed to create commit" 88 | exit 1 89 | fi 90 | 91 | echo "Pushing to main branch..." 92 | if ! git push; then 93 | echo "Error: Failed to push changes" 94 | exit 1 95 | fi 96 | 97 | echo "Successfully updated repository context" 98 | else 99 | echo "No changes detected in repository_context.txt" 100 | fi 101 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | /test-results/ 39 | /playwright-report/ 40 | /playwright/.cache/ 41 | src/pages/registrer.tsx 42 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "jsxBracketSameLine": false, 4 | "singleQuote": true, 5 | "jsxSingleQuote": false 6 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at . All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via issue, 4 | email, or any other method with the owners of this repository before making a change. 5 | 6 | Please note we have a code of conduct, please follow it in all your interactions with the project. 7 | 8 | ## Pull Request Process 9 | 10 | 1. Ensure any install or build dependencies are removed before the end of the layer when doing a 11 | build. 12 | 2. Update the README.md with details of changes to the interface, this includes new environment 13 | variables, exposed ports, useful file locations and container parameters. 14 | 3. Increase the version numbers in any examples files and the README.md to the new version that this 15 | Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). 16 | 4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you 17 | do not have permission to do that, you may request the second reviewer to merge it for you. 18 | 19 | ## Code of Conduct 20 | 21 | ### Our Pledge 22 | 23 | In the interest of fostering an open and welcoming environment, we as 24 | contributors and maintainers pledge to making participation in our project and 25 | our community a harassment-free experience for everyone, regardless of age, body 26 | size, disability, ethnicity, gender identity and expression, level of experience, 27 | nationality, personal appearance, race, religion, or sexual identity and 28 | orientation. 29 | 30 | ### Our Standards 31 | 32 | Examples of behavior that contributes to creating a positive environment 33 | include: 34 | 35 | - Using welcoming and inclusive language 36 | - Being respectful of differing viewpoints and experiences 37 | - Gracefully accepting constructive criticism 38 | - Focusing on what is best for the community 39 | - Showing empathy towards other community members 40 | 41 | Examples of unacceptable behavior by participants include: 42 | 43 | - The use of sexualized language or imagery and unwelcome sexual attention or 44 | advances 45 | - Trolling, insulting/derogatory comments, and personal or political attacks 46 | - Public or private harassment 47 | - Publishing others' private information, such as a physical or electronic 48 | address, without explicit permission 49 | - Other conduct which could reasonably be considered inappropriate in a 50 | professional setting 51 | 52 | ### Our Responsibilities 53 | 54 | Project maintainers are responsible for clarifying the standards of acceptable 55 | behavior and are expected to take appropriate and fair corrective action in 56 | response to any instances of unacceptable behavior. 57 | 58 | Project maintainers have the right and responsibility to remove, edit, or 59 | reject comments, commits, code, wiki edits, issues, and other contributions 60 | that are not aligned to this Code of Conduct, or to ban temporarily or 61 | permanently any contributor for other behaviors that they deem inappropriate, 62 | threatening, offensive, or harmful. 63 | 64 | ### Scope 65 | 66 | This Code of Conduct applies both within project spaces and in public spaces 67 | when an individual is representing the project or its community. Examples of 68 | representing a project or community include using an official project e-mail 69 | address, posting via an official social media account, or acting as an appointed 70 | representative at an online or offline event. Representation of a project may be 71 | further defined and clarified by project maintainers. 72 | 73 | ### Enforcement 74 | 75 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 76 | reported by contacting the project team at [INSERT EMAIL ADDRESS]. All 77 | complaints will be reviewed and investigated and will result in a response that 78 | is deemed necessary and appropriate to the circumstances. The project team is 79 | obligated to maintain confidentiality with regard to the reporter of an incident. 80 | Further details of specific enforcement policies may be posted separately. 81 | 82 | Project maintainers who do not follow or enforce the Code of Conduct in good 83 | faith may face temporary or permanent repercussions as determined by other 84 | members of the project's leadership. 85 | 86 | ### Attribution 87 | 88 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 89 | available at [http://contributor-covenant.org/version/1/4][version] 90 | 91 | [homepage]: http://contributor-covenant.org 92 | 93 | [version]: http://contributor-covenant.org/version/1/4/ 94 | -------------------------------------------------------------------------------- /DOCS/PROJECT_REVIEW.md: -------------------------------------------------------------------------------- 1 | # Project Review & Recommendations 2 | 3 | ## Overall Assessment 4 | 5 | The project demonstrates solid engineering practices with a well-structured codebase. The separation of concerns is clear, components are modular, and the state management approach is pragmatic. 6 | 7 | ## Key Strengths 8 | 9 | 1. **Cart Implementation** 10 | - Clean separation between UI and state management 11 | - Effective use of React Context for global state 12 | - Smart persistence strategy with localStorage 13 | 14 | 2. **Checkout Flow** 15 | - Well-orchestrated process with clear state handling 16 | - Good use of react-hook-form for form management 17 | - Clear loading/error/success states 18 | 19 | ## Recommended Improvements 20 | 21 | ### 1. State Management Refinements 22 | - Currently exposes raw `setCart` function in CartProvider, which could lead to inconsistent state 23 | - Recommendation: Replace with specific action functions: 24 | ```typescript 25 | { 26 | addToCart: (product: Product) => void; 27 | removeFromCart: (productId: string) => void; 28 | updateQuantity: (productId: string, quantity: number) => void; 29 | clearCart: () => void; 30 | } 31 | ``` 32 | 33 | ### 2. Security Enhancements 34 | - Session tokens stored in localStorage are vulnerable to XSS 35 | - Consider implementing: 36 | - HttpOnly cookies for session management 37 | - CSRF protection for mutations 38 | - Rate limiting on checkout endpoints 39 | 40 | ### 3. Payment System Flexibility 41 | - Currently hardcoded to Cash on Delivery 42 | - Suggested improvements: 43 | - Abstract payment method selection 44 | - Implement payment gateway integration interface 45 | - Add support for multiple payment providers 46 | 47 | ### 4. Error Handling 48 | - Add comprehensive error boundaries 49 | - Implement retry logic for failed GraphQL operations 50 | - Add detailed error logging and monitoring 51 | - Consider implementing offline support/queue for cart operations 52 | 53 | ### 5. Performance Optimizations 54 | - Implement cart item quantity debouncing 55 | - Add product list virtualization for large catalogs 56 | - Consider implementing optimistic UI updates 57 | - Add prefetching for common user paths 58 | 59 | ### 6. Developer Experience 60 | - Add more comprehensive TypeScript types 61 | - Consider implementing Storybook for component development 62 | - Add unit tests for critical business logic 63 | - Implement automated accessibility testing 64 | 65 | ### 7. User Experience 66 | - Add toast notifications for cart operations 67 | - Implement better loading skeletons 68 | - Add offline support indicators 69 | - Improve form error messaging and validation feedback 70 | 71 | ## Priority Recommendations 72 | 73 | 1. **High Priority** 74 | - Secure session management (move from localStorage to HttpOnly cookies) 75 | - Implement specific cart action functions instead of exposing setCart 76 | - Add comprehensive error handling 77 | 78 | 2. **Medium Priority** 79 | - Flexible payment gateway integration 80 | - Performance optimizations 81 | - Enhanced error feedback 82 | 83 | 3. **Nice to Have** 84 | - Developer experience improvements 85 | - Additional UX enhancements 86 | - Automated testing expansion 87 | -------------------------------------------------------------------------------- /DOCS/SUGGESTIONS.md: -------------------------------------------------------------------------------- 1 | # Senior-Level Improvements 2 | 3 | ## 1. Architecture & State Management 4 | 5 | ### Global State Management 6 | - **Replace Context API with Redux Toolkit or Zustand** 7 | - Gain: Better state management, dev tools, middleware support 8 | - Example: Move cart state to Redux with proper slices and actions 9 | 10 | ```typescript 11 | // Example Redux slice for cart 12 | const cartSlice = createSlice({ 13 | name: 'cart', 14 | initialState, 15 | reducers: { 16 | addToCart: (state, action) => { 17 | // Immutable state updates with Redux Toolkit 18 | }, 19 | removeFromCart: (state, action) => { 20 | // Automatic handling of immutability 21 | } 22 | } 23 | }); 24 | ``` 25 | 26 | ### Service Layer 27 | - **API Abstraction** 28 | - Gain: Better separation of concerns, easier testing and maintenance 29 | - Example: Create dedicated service classes for API operations 30 | 31 | ```typescript 32 | class ProductService { 33 | private api: ApiClient; 34 | 35 | async getProducts(filters: ProductFilters): Promise { 36 | // Centralized error handling and response mapping 37 | } 38 | } 39 | ``` 40 | 41 | ## 2. Performance Optimizations 42 | 43 | ### Code Splitting 44 | - **Dynamic Imports** 45 | - Gain: Smaller initial bundle size, faster page loads 46 | - Example: Lazy load product filters on mobile 47 | 48 | ```typescript 49 | const ProductFilters = dynamic(() => import('./ProductFilters'), { 50 | loading: () => , 51 | ssr: false 52 | }); 53 | ``` 54 | 55 | ### Caching Strategy 56 | - **Apollo Client Caching** 57 | - Gain: Faster data access, reduced server load 58 | - Example: Implement field-level caching policies 59 | 60 | ```typescript 61 | const cache = new InMemoryCache({ 62 | typePolicies: { 63 | Product: { 64 | fields: { 65 | price: { 66 | read(price) { 67 | // Custom cache reading logic 68 | } 69 | } 70 | } 71 | } 72 | } 73 | }); 74 | ``` 75 | 76 | ## 3. Testing & Quality Assurance 77 | 78 | ### Unit Testing 79 | - **Jest/React Testing Library** 80 | - Gain: Catch bugs early, ensure component behavior 81 | - Example: Test hooks like useProductFilters in isolation 82 | 83 | ```typescript 84 | describe('useProductFilters', () => { 85 | it('should filter products by price range', () => { 86 | const { result } = renderHook(() => useProductFilters()); 87 | act(() => { 88 | result.current.setPriceRange([10, 50]); 89 | }); 90 | expect(result.current.filterProducts(mockProducts)).toEqual( 91 | expect.arrayContaining([ 92 | expect.objectContaining({ price: expect.any(Number) }) 93 | ]) 94 | ); 95 | }); 96 | }); 97 | ``` 98 | 99 | ### E2E Testing 100 | - **Expand Playwright Tests** 101 | - Gain: Ensure critical user flows work end-to-end 102 | - Example: Add comprehensive checkout flow testing 103 | 104 | ```typescript 105 | test('complete checkout process', async ({ page }) => { 106 | await page.goto('/'); 107 | await page.click('[data-testid="product-card"]'); 108 | await page.click('[data-testid="add-to-cart"]'); 109 | // Test entire checkout flow 110 | }); 111 | ``` 112 | 113 | ## 4. WooCommerce Integration Enhancements 114 | 115 | ### Session Management 116 | - **Improve WooCommerce Session Handling** 117 | - Gain: Better cart persistence, reduced errors 118 | - Example: Enhanced session token management 119 | 120 | ```typescript 121 | // Enhanced WooCommerce session middleware 122 | const enhancedMiddleware = new ApolloLink((operation, forward) => { 123 | const session = getWooSession(); 124 | if (session && !isExpired(session)) { 125 | operation.setContext({ 126 | headers: { 127 | 'woocommerce-session': `Session ${session.token}` 128 | } 129 | }); 130 | } 131 | return forward(operation); 132 | }); 133 | ``` 134 | 135 | ### Cart Improvements 136 | - **Enhanced Cart Features** 137 | - Gain: Better user experience with cart functionality 138 | - Example: Add cart total, copy billing address to shipping 139 | 140 | ## 5. Developer Experience 141 | 142 | ### Documentation 143 | - **Storybook Integration** 144 | - Gain: Better component documentation, easier UI development 145 | - Example: Document all variants of ProductCard 146 | 147 | ```typescript 148 | // ProductCard.stories.tsx 149 | export const WithDiscount = { 150 | args: { 151 | product: { 152 | name: 'Test Product', 153 | price: '100', 154 | salePrice: '80', 155 | onSale: true 156 | } 157 | } 158 | }; 159 | ``` 160 | 161 | ### TypeScript Improvements 162 | - **Stricter Configuration** 163 | - Gain: Catch more bugs at compile time 164 | - Example: Enable strict mode, add proper generics 165 | 166 | ```typescript 167 | // tsconfig.json improvements 168 | { 169 | "compilerOptions": { 170 | "strict": true, 171 | "noUncheckedIndexedAccess": true, 172 | "exactOptionalPropertyTypes": true 173 | } 174 | } 175 | ``` 176 | 177 | ## 6. Monitoring & Analytics 178 | 179 | ### Error Tracking 180 | - **Sentry Integration** 181 | - Gain: Better error tracking, faster bug fixing 182 | - Example: Add proper error boundaries with Sentry 183 | 184 | ```typescript 185 | class ErrorBoundary extends React.Component { 186 | componentDidCatch(error, errorInfo) { 187 | Sentry.captureException(error, { extra: errorInfo }); 188 | } 189 | } 190 | ``` 191 | 192 | ### Performance Monitoring 193 | - **Core Web Vitals** 194 | - Gain: Track and improve user experience metrics 195 | - Example: Implement proper performance monitoring 196 | 197 | ## 7. Code Quality & Maintainability 198 | 199 | ### Design Patterns 200 | - **Implement Factory Pattern** 201 | - Gain: Better code organization, easier maintenance 202 | - Example: Create product factory for different types 203 | 204 | ```typescript 205 | class ProductFactory { 206 | createProduct(type: ProductType, data: ProductData): Product { 207 | switch (type) { 208 | case 'simple': 209 | return new SimpleProduct(data); 210 | case 'variable': 211 | return new VariableProduct(data); 212 | default: 213 | throw new Error(`Unknown product type: ${type}`); 214 | } 215 | } 216 | } 217 | ``` 218 | 219 | ### Code Organization 220 | - **Feature-based Structure** 221 | - Gain: Better code organization, easier navigation 222 | - Example: Reorganize code by feature instead of type 223 | 224 | ``` 225 | src/ 226 | features/ 227 | products/ 228 | components/ 229 | hooks/ 230 | services/ 231 | types/ 232 | cart/ 233 | components/ 234 | hooks/ 235 | services/ 236 | types/ 237 | ``` 238 | 239 | ## Implementation Priority Matrix 240 | 241 | ### High Impact, Low Effort (Do First) 242 | 1. **TypeScript Strict Mode** 243 | - Simply update tsconfig.json 244 | - Immediate impact on code quality 245 | - Catches type-related bugs early 246 | 247 | 2. **Lighthouse Score Improvements** 248 | - Already have CI integration 249 | - Focus on performance metrics 250 | - Quick accessibility wins 251 | 252 | 3. **Cart Total Implementation** 253 | - Listed in TODO 254 | - High user impact 255 | - Relatively simple change 256 | 257 | ### High Impact, High Effort (Plan Carefully) 258 | 1. **State Management Refactor** 259 | - Requires significant refactoring 260 | - Major architectural improvement 261 | - Plan and implement in phases 262 | 263 | 2. **Feature-based Code Reorganization** 264 | - Substantial restructuring needed 265 | - Improves long-term maintainability 266 | - Requires team coordination 267 | 268 | ### Low Impact, Low Effort (Quick Wins) 269 | 1. **Storybook Documentation** 270 | - Can be added gradually 271 | - Improves developer experience 272 | - Start with key components 273 | 274 | 2. **Performance Monitoring** 275 | - Easy integration with existing tools 276 | - Provides valuable insights 277 | - Quick setup process 278 | 279 | ### Low Impact, High Effort (Consider Later) 280 | 1. **Expand Test Coverage** 281 | - Build upon existing Playwright E2E tests 282 | - Already have basic homepage tests 283 | - Focus on: 284 | - WooCommerce integration tests 285 | - Cart/checkout flows 286 | - Variable product handling 287 | - Stock status updates 288 | 289 | 2. **User Registration & Dashboard** 290 | - Listed in TODO 291 | - Requires careful WooCommerce integration 292 | - Consider after core improvements 293 | 294 | ## Implementation Strategy 295 | 296 | 1. **Week 1-2: Quick Wins** 297 | - Enable TypeScript strict mode 298 | - Add error boundaries 299 | - Optimize Apollo cache 300 | - Estimated effort: 3-4 days 301 | - Immediate quality improvements 302 | 303 | 2. **Week 3-4: Foundation Building** 304 | - Begin Storybook documentation 305 | - Set up performance monitoring 306 | - Expand existing E2E tests with: 307 | - Cart manipulation scenarios 308 | - Checkout flow validation 309 | - Error state handling 310 | - Estimated effort: 5-7 days 311 | - Builds upon existing test infrastructure 312 | 313 | 3. **Month 2: Major Improvements** 314 | - Implement user registration flow 315 | - Add cart improvements from TODO list 316 | - Enhance WooCommerce session handling 317 | - Estimated effort: 3-4 weeks 318 | - Focus on core user experience 319 | 320 | This prioritization ensures: 321 | - Quick delivery of high-impact improvements 322 | - Minimal disruption to ongoing development 323 | - Measurable progress at each stage 324 | - Efficient use of development resources 325 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Lighthouse CI](https://github.com/w3bdesign/nextjs-woocommerce/actions/workflows/lighthouse.yml/badge.svg)](https://github.com/w3bdesign/nextjs-woocommerce/actions/workflows/lighthouse.yml) 2 | [![Playwright Tests](https://github.com/w3bdesign/nextjs-woocommerce/actions/workflows/playwright.yml/badge.svg)](https://github.com/w3bdesign/nextjs-woocommerce/actions/workflows/playwright.yml) 3 | [![Codacy Badge](https://api.codacy.com/project/badge/Grade/29de6847b01142e6a0183988fc3df46a)](https://app.codacy.com/gh/w3bdesign/nextjs-woocommerce?utm_source=github.com&utm_medium=referral&utm_content=w3bdesign/nextjs-woocommerce&utm_campaign=Badge_Grade_Settings) 4 | [![CodeFactor](https://www.codefactor.io/repository/github/w3bdesign/nextjs-woocommerce/badge)](https://www.codefactor.io/repository/github/w3bdesign/nextjs-woocommerce) 5 | [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=w3bdesign_nextjs-woocommerce&metric=alert_status)](https://sonarcloud.io/dashboard?id=w3bdesign_nextjs-woocommerce) 6 | 7 | ![bilde](https://github.com/user-attachments/assets/08047025-0950-472a-ae7d-932c7faee1db) 8 | 9 | ## Star History 10 | 11 | [![Star History Chart](https://api.star-history.com/svg?repos=w3bdesign/nextjs-woocommerce&type=Date)](https://star-history.com/#w3bdesign/nextjs-woocommerce&Date) 12 | 13 | # Next.js Ecommerce site with WooCommerce backend 14 | 15 | ## Live URL: 16 | 17 | ## Table Of Contents (TOC) 18 | 19 | - [Installation](#Installation) 20 | - [Features](#Features) 21 | - [Lighthouse Performance Monitoring](#lighthouse-performance-monitoring) 22 | - [Issues](#Issues) 23 | - [Troubleshooting](#Troubleshooting) 24 | - [TODO](#TODO) 25 | - [Future Improvements](SUGGESTIONS.md) 26 | 27 | ## Installation 28 | 29 | 1. Install and activate the following required plugins, in your WordPress plugin directory: 30 | 31 | - [woocommerce](https://wordpress.org/plugins/woocommerce) Ecommerce for WordPress. 32 | - [wp-graphql](https://wordpress.org/plugins/wp-graphql) Exposes GraphQL for WordPress. 33 | - [wp-graphql-woocommerce](https://github.com/wp-graphql/wp-graphql-woocommerce) Adds WooCommerce functionality to a WPGraphQL schema. 34 | - [wp-algolia-woo-indexer](https://github.com/w3bdesign/wp-algolia-woo-indexer) WordPress plugin coded by me. Sends WooCommerce products to Algolia. Required for search to work. 35 | 36 | Optional plugin: 37 | 38 | - [headless-wordpress](https://github.com/w3bdesign/headless-wp) Disables the frontend so only the backend is accessible. (optional) 39 | 40 | The current release has been tested and is confirmed working with the following versions: 41 | 42 | - WordPress version 6.6.2 43 | - WooCommerce version 7.4.0 44 | - WP GraphQL version 1.13.8 45 | - WooGraphQL version 0.12.0 46 | - WPGraphQL CORS version 2.1 47 | 48 | 2. For debugging and testing, install either: 49 | 50 | (Firefox) 51 | 52 | (Chrome) 53 | 54 | 3. Make sure WooCommerce has some products already 55 | 56 | 4. Clone or fork the repo and modify `.env.example` and rename it to `.env` 57 | 58 | Then set the environment variables accordingly in Vercel or your preferred hosting solution. 59 | 60 | See 61 | 62 | 5. Modify the values according to your setup 63 | 64 | 6. Start the server with `npm run dev` 65 | 66 | 7. Enable COD (Cash On Demand) payment method in WooCommerce 67 | 68 | 8. Add a product to the cart 69 | 70 | 9. Proceed to checkout (Gå til kasse) 71 | 72 | 10. Fill in your details and place the order 73 | 74 | ## Features 75 | 76 | - Next.js version 15.1.7 77 | - React version 18.3.1 78 | - Typescript 79 | - Tests with Playwright 80 | - Connect to Woocommerce GraphQL API and list name, price and display image for products 81 | - Support for simple products and variable products 82 | - Cart handling and checkout with WooCommerce using Zustand for state management 83 | - Persistent cart state with localStorage sync 84 | - Efficient updates through selective subscriptions 85 | - Type-safe cart operations 86 | - Cash On Delivery payment method 87 | - Algolia search (requires [algolia-woo-indexer](https://github.com/w3bdesign/algolia-woo-indexer)) 88 | - Meets WCAG accessibility standards where possible 89 | - Placeholder for products without images 90 | - State Management: 91 | - Zustand for global state management 92 | - Apollo Client with GraphQL 93 | - React Hook Form 94 | - Native HTML5 form validation 95 | - Animations with Framer motion, Styled components and Animate.css 96 | - Loading spinner created with Styled Components 97 | - Shows page load progress with Nprogress during navigation 98 | - Fully responsive design 99 | - Category and product listings 100 | - Show stock status 101 | - Pretty URLs with builtin Nextjs functionality 102 | - Tailwind 3 for styling 103 | - JSDoc comments 104 | - Automated Lighthouse performance monitoring 105 | - Continuous performance, accessibility, and best practices checks 106 | - Automated reports on every pull request 107 | - Performance metrics tracking for: 108 | - Performance score 109 | - Accessibility compliance 110 | - Best practices adherence 111 | - SEO optimization 112 | - Progressive Web App readiness 113 | - Product filtering: 114 | - Dynamic color filtering using Tailwind's color system 115 | - Mobile-optimized filter layout 116 | - Accessible form controls with ARIA labels 117 | - Price range slider 118 | - Size and color filters 119 | - Product type categorization 120 | - Sorting options (popularity, price, newest) 121 | 122 | ## Lighthouse Performance Monitoring 123 | 124 | This project uses automated Lighthouse testing through GitHub Actions to ensure high-quality web performance. On every pull request: 125 | 126 | - Performance, accessibility, best practices, and SEO are automatically evaluated 127 | - Results are posted directly to the pull request 128 | - Minimum score thresholds are enforced for: 129 | - Performance: Analyzing loading performance, interactivity, and visual stability 130 | - Accessibility: Ensuring WCAG compliance and inclusive design 131 | - Best Practices: Validating modern web development standards 132 | - SEO: Checking search engine optimization fundamentals 133 | - PWA: Assessing Progressive Web App capabilities 134 | 135 | View the latest Lighthouse results in the GitHub Actions tab under the "Lighthouse Check" workflow. 136 | 137 | ## Troubleshooting 138 | 139 | ### I am getting a cart undefined error or other GraphQL errors 140 | 141 | Check that you are using the 0.12.0 version of the [wp-graphql-woocommerce](https://github.com/wp-graphql/wp-graphql-woocommerce) plugin 142 | 143 | ### The products page isn't loading 144 | 145 | Check the attributes of the products. Right now the application requires Size and Color. 146 | 147 | ## Issues 148 | 149 | Overall the application is working as intended, but it has not been tested extensively in a production environment. 150 | More testing and debugging is required before deploying it in a production environment. 151 | 152 | With that said, keep the following in mind: 153 | 154 | - Currently only simple products and variable products work without any issues. Other product types are not known to work. 155 | - Only Cash On Delivery (COD) is currently supported. More payment methods may be added later. 156 | 157 | This project is tested with BrowserStack. 158 | 159 | ## TODO 160 | 161 | - Implement UserRegistration.component.tsx in a registration page 162 | - Add user dashboard with order history 163 | - Add Cloudflare Turnstile on registration page 164 | - Ensure email is real on registration page 165 | - Add total to cart/checkout page 166 | - Copy billing address to shipping address 167 | - Hide products not in stock 168 | -------------------------------------------------------------------------------- /lighthouserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "ci": { 3 | "collect": { 4 | "numberOfRuns": 1, 5 | "url": [ 6 | "http://localhost:3000/", 7 | "http://localhost:3000/produkter", 8 | "http://localhost:3000/kategorier" 9 | ], 10 | "port": 3000, 11 | "settings": { 12 | "onlyCategories": [ 13 | "performance", 14 | "accessibility", 15 | "best-practices", 16 | "seo" 17 | ], 18 | "skipAudits": ["uses-http2"], 19 | "throttling": { 20 | "method": "simulate" 21 | } 22 | } 23 | }, 24 | "upload": { "target": "temporary-public-storage" }, 25 | "assert": { 26 | "assertions": { 27 | "categories:performance": ["warn", { "minScore": 0.8 }], 28 | "categories:accessibility": ["warn", { "minScore": 0.9 }], 29 | "categories:best-practices": ["warn", { "minScore": 0.9 }], 30 | "categories:seo": ["warn", { "minScore": 0.9 }], 31 | "uses-http2": "off" 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | images: { 5 | remotePatterns: [ 6 | { 7 | protocol: 'https', 8 | hostname: 'swewoocommerce.dfweb.no', 9 | pathname: '**', 10 | }, 11 | { 12 | protocol: 'https', 13 | hostname: 'res.cloudinary.com', 14 | pathname: '**', 15 | }, 16 | { 17 | protocol: 'https', 18 | hostname: 'via.placeholder.com', 19 | pathname: '**', 20 | }, 21 | ], 22 | }, 23 | }; 24 | 25 | module.exports = nextConfig; 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-woocommerce", 3 | "version": "1.2.4", 4 | "private": true, 5 | "scripts": { 6 | "lhci": "lhci autorun", 7 | "lhci:perf": "lhci autorun --collect.settings.preset=perf", 8 | "lhci:desktop": "lhci autorun --collect.settings.preset=desktop", 9 | "dev": "next dev --turbopack", 10 | "build": "next build", 11 | "start": "next start", 12 | "lint": "next lint", 13 | "format": "prettier --write \"**/*.{js,ts,tsx,json}\"", 14 | "playwright": "npx playwright test", 15 | "playwright:ui": "npx playwright test --ui", 16 | "playwright:debug": "npx playwright test --debug", 17 | "playwright:codegen": "playwright codegen", 18 | "refresh": "rm -rf node_modules && rm package-lock.json && npm i && npm run format" 19 | }, 20 | "keywords": [ 21 | "next.js", 22 | "next", 23 | "javascript", 24 | "framer" 25 | ], 26 | "author": "w3bdesign", 27 | "license": "ISC", 28 | "dependencies": { 29 | "@apollo/client": "^3.13.8", 30 | "@types/react": "^19.1.6", 31 | "algoliasearch": "^4.24.0", 32 | "autoprefixer": "^10.4.21", 33 | "framer-motion": "12.16.0", 34 | "graphql": "^16.11.0", 35 | "lodash": "^4.17.21", 36 | "next": "15.3.3", 37 | "nprogress": "^0.2.0", 38 | "postcss": "^8.5.4", 39 | "react": "18.3.1", 40 | "react-dom": "18.3.1", 41 | "react-hook-form": "^7.57.0", 42 | "react-instantsearch-dom": "^6.40.4", 43 | "uuid": "^11.1.0", 44 | "zustand": "^5.0.5" 45 | }, 46 | "devDependencies": { 47 | "@lhci/cli": "^0.14.0", 48 | "@playwright/test": "^1.52.0", 49 | "@types/lodash": "^4.17.17", 50 | "@types/node": "22.15.29", 51 | "@types/nprogress": "^0.2.3", 52 | "@types/react-instantsearch-dom": "^6.12.9", 53 | "@types/uuid": "^10.0.0", 54 | "@typescript-eslint/eslint-plugin": "^8.33.1", 55 | "@typescript-eslint/parser": "^8.33.1", 56 | "babel-plugin-styled-components": "^2.1.4", 57 | "eslint-config-next": "^15.3.3", 58 | "postcss-preset-env": "^10.2.0", 59 | "prettier": "^3.5.3", 60 | "tailwindcss": "^3.4.17", 61 | "typescript": "^5.8.3" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from '@playwright/test'; 2 | import { devices } from '@playwright/test'; 3 | 4 | /** 5 | * Read environment variables from file. 6 | * https://github.com/motdotla/dotenv 7 | */ 8 | 9 | /** 10 | * See https://playwright.dev/docs/test-configuration. 11 | */ 12 | const config: PlaywrightTestConfig = { 13 | testDir: './src/tests', 14 | /* Maximum time one test can run for. */ 15 | timeout: 60 * 1000, // Increased to 60 seconds 16 | expect: { 17 | /** 18 | * Maximum time expect() should wait for the condition to be met. 19 | * For example in `await expect(locator).toHaveText();` 20 | */ 21 | timeout: 30000, // Increased to 30 seconds 22 | }, 23 | /* Run tests in files in parallel */ 24 | fullyParallel: true, 25 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 26 | forbidOnly: Boolean(process.env.CI), 27 | /* Retry on CI only */ 28 | retries: process.env.CI ? 2 : 0, 29 | /* Opt out of parallel tests on CI. */ 30 | workers: process.env.CI ? 1 : '100%', 31 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 32 | reporter: process.env.CI ? 'github' : 'html', 33 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 34 | use: { 35 | /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ 36 | actionTimeout: 30000, // Added 30 second timeout for actions 37 | /* Base URL to use in actions like `await page.goto('/')`. */ 38 | baseURL: 'http://localhost:3000', 39 | 40 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 41 | trace: 'on-first-retry', 42 | 43 | viewport: { width: 2560, height: 1440 }, 44 | }, 45 | 46 | /* Configure projects for major browsers */ 47 | projects: [ 48 | { 49 | name: 'chromium', 50 | use: { 51 | ...devices['Desktop Chrome'], 52 | }, 53 | }, 54 | 55 | { 56 | name: 'firefox', 57 | use: { 58 | ...devices['Desktop Firefox'], 59 | }, 60 | }, 61 | 62 | { 63 | name: 'webkit', 64 | use: { 65 | ...devices['Desktop Safari'], 66 | }, 67 | }, 68 | 69 | /* Test against mobile viewports. */ 70 | // { 71 | // name: 'Mobile Chrome', 72 | // use: { 73 | // ...devices['Pixel 5'], 74 | // }, 75 | // }, 76 | // { 77 | // name: 'Mobile Safari', 78 | // use: { 79 | // ...devices['iPhone 12'], 80 | // }, 81 | // }, 82 | 83 | /* Test against branded browsers. */ 84 | // { 85 | // name: 'Microsoft Edge', 86 | // use: { 87 | // channel: 'msedge', 88 | // }, 89 | // }, 90 | // { 91 | // name: 'Google Chrome', 92 | // use: { 93 | // channel: 'chrome', 94 | // }, 95 | // }, 96 | ], 97 | 98 | /* Folder for test artifacts such as screenshots, videos, traces, etc. */ 99 | outputDir: 'test-results/', 100 | 101 | /* Run your local dev server before starting the tests */ 102 | webServer: { 103 | reuseExistingServer: true, 104 | command: 'npm run dev', 105 | port: 3000, 106 | timeout: 120000, // Added 2 minute timeout for server start 107 | }, 108 | }; 109 | 110 | export default config; 111 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3bdesign/nextjs-woocommerce/603068a80b9ad9656812a84137e21f2ffec005bb/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3bdesign/nextjs-woocommerce/603068a80b9ad9656812a84137e21f2ffec005bb/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3bdesign/nextjs-woocommerce/603068a80b9ad9656812a84137e21f2ffec005bb/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/images/hero.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3bdesign/nextjs-woocommerce/603068a80b9ad9656812a84137e21f2ffec005bb/public/images/hero.jpg -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:recommended"], 3 | "ignorePresets": [":prHourlyLimit2", ":prConcurrentLimit20"], 4 | "packageRules": [ 5 | { 6 | "rangeStrategy": "bump", 7 | "matchDepTypes": [ 8 | "dependencies", 9 | "devDependencies", 10 | "optionalDependencies", 11 | "peerDependencies" 12 | ] 13 | }, 14 | { 15 | "matchUpdateTypes": ["minor", "pin", "digest"], 16 | "automerge": true, 17 | "matchDepTypes": [ 18 | "dependencies", 19 | "devDependencies", 20 | "optionalDependencies", 21 | "peerDependencies" 22 | ] 23 | }, 24 | { 25 | "matchUpdateTypes": ["patch", "lockFileMaintenance"], 26 | "automerge": true, 27 | "automergeType": "branch", 28 | "matchDepTypes": [ 29 | "dependencies", 30 | "devDependencies", 31 | "optionalDependencies", 32 | "peerDependencies" 33 | ] 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /screenshots/screenshot1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3bdesign/nextjs-woocommerce/603068a80b9ad9656812a84137e21f2ffec005bb/screenshots/screenshot1.jpg -------------------------------------------------------------------------------- /src/components/AlgoliaSearch/AlgoliaSearchBox.component.tsx: -------------------------------------------------------------------------------- 1 | import algoliasearch from 'algoliasearch'; 2 | import { InstantSearch, SearchBox, Hits } from 'react-instantsearch-dom'; 3 | import { useState } from 'react'; 4 | 5 | import SearchResults from './SearchResults.component'; 6 | 7 | const searchClient = algoliasearch( 8 | process.env.NEXT_PUBLIC_ALGOLIA_APP_ID ?? 'changeme', 9 | process.env.NEXT_PUBLIC_ALGOLIA_PUBLIC_API_KEY ?? 'changeme', 10 | ); 11 | 12 | // https://www.algolia.com/doc/api-reference/widgets/instantsearch/react/ 13 | 14 | /** 15 | * Displays Algolia search for larger resolutions that do not show the mobile menu 16 | */ 17 | const AlgoliaSearchBox = () => { 18 | const [search, setSearch] = useState(null); 19 | const [hasFocus, sethasFocus] = useState(false); 20 | 21 | return ( 22 |
23 |
24 | 28 | {/*We need to conditionally add a border because the element has position:fixed*/} 29 | { 40 | const target = event.target as HTMLInputElement; 41 | sethasFocus(true); 42 | setSearch(target.value); 43 | }} 44 | onKeyDown={(event) => { 45 | const target = event.target as HTMLInputElement; 46 | sethasFocus(true); 47 | setSearch(target.value); 48 | }} 49 | onReset={() => { 50 | setSearch(null); 51 | }} 52 | /> 53 | {search && ( 54 |
55 | 56 |
57 | )} 58 |
59 |
60 |
61 | ); 62 | }; 63 | 64 | export default AlgoliaSearchBox; 65 | -------------------------------------------------------------------------------- /src/components/AlgoliaSearch/MobileSearch.component.tsx: -------------------------------------------------------------------------------- 1 | import algoliasearch from 'algoliasearch'; 2 | import { InstantSearch, SearchBox, Hits } from 'react-instantsearch-dom'; 3 | import { useState } from 'react'; 4 | 5 | import SearchResults from './SearchResults.component'; 6 | 7 | const searchClient = algoliasearch( 8 | process.env.NEXT_PUBLIC_ALGOLIA_APP_ID ?? 'changethis', 9 | process.env.NEXT_PUBLIC_ALGOLIA_PUBLIC_API_KEY ?? 'changethis', 10 | ); 11 | 12 | /** 13 | * Algolia search for mobile menu. 14 | */ 15 | const MobileSearch = () => { 16 | const [search, setSearch] = useState(null); 17 | const [hasFocus, sethasFocus] = useState(false); 18 | return ( 19 |
20 | 24 | { 34 | setSearch(null); 35 | }} 36 | onChange={(event) => { 37 | const target = event.target as HTMLInputElement; 38 | sethasFocus(true); 39 | setSearch(target.value); 40 | }} 41 | onKeyDown={(event) => { 42 | const target = event.target as HTMLInputElement; 43 | sethasFocus(true); 44 | setSearch(target.value); 45 | }} 46 | /> 47 | {search && } 48 | 49 |
50 | ); 51 | }; 52 | 53 | export default MobileSearch; 54 | -------------------------------------------------------------------------------- /src/components/AlgoliaSearch/SearchResults.component.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { trimmedStringToLength } from '@/utils/functions/functions'; 3 | 4 | interface ISearchResultProps { 5 | hit: { 6 | product_image: string; 7 | product_name: string; 8 | regular_price: string; 9 | sale_price: string; 10 | on_sale: boolean; 11 | short_description: string; 12 | slug: string; 13 | }; 14 | } 15 | 16 | /** 17 | * Displays search results from Algolia 18 | * @param {object} hit { 19 | * @param {string} product_image Product image from WooCommerce 20 | * @param {string} product_name Name of product 21 | * @param {string} regular_price Price without discount 22 | * @param {string} sale_price Price when on sale 23 | * @param {boolean} on_sale Is the product on sale? True or false 24 | * @param {string} short_description Short description of product 25 | } 26 | */ 27 | const SearchResults = ({ 28 | hit: { 29 | product_image, 30 | product_name, 31 | regular_price, 32 | sale_price, 33 | on_sale, 34 | short_description, 35 | }, 36 | }: ISearchResultProps) => { 37 | return ( 38 |
39 | 43 |
44 |
45 | {product_name} 50 |
51 |
52 | {product_name && ( 53 | {product_name} 54 | )} 55 |
56 | {on_sale && ( 57 | <> 58 | 59 | kr {regular_price} 60 | 61 | kr {sale_price} 62 | 63 | )} 64 | {!on_sale && kr {regular_price}} 65 |
66 | 67 | {trimmedStringToLength(short_description, 30)} 68 | 69 |
70 |
71 | 72 |
73 | ); 74 | }; 75 | 76 | export default SearchResults; 77 | -------------------------------------------------------------------------------- /src/components/Animations/FadeLeftToRight.component.tsx: -------------------------------------------------------------------------------- 1 | // CircleCI doesn't like import { motion } from "framer-motion" here, so we use require 2 | const { motion } = require('framer-motion'); 3 | 4 | import type { IAnimateStaggerWithDelayProps } from './types/Animations.types'; 5 | 6 | /** 7 | * Fade content left to right. Needs to be used with FadeLeftToRightItem 8 | * @function FadeLeftToRight 9 | * @param {ReactNode} children - Children content to render 10 | * @param {string} cssClass - CSS classes to apply to component 11 | * @param {number} delay - Time to wait before starting animation 12 | * @param {number} staggerDelay - Time to wait before starting animation for children items 13 | * @param {boolean} animateNotReverse - Start animation backwards 14 | * @returns {JSX.Element} - Rendered component 15 | */ 16 | 17 | const FadeLeftToRight = ({ 18 | children, 19 | cssClass, 20 | delay, 21 | staggerDelay, 22 | animateNotReverse, 23 | }: IAnimateStaggerWithDelayProps) => { 24 | const FadeLeftToRightVariants = { 25 | visible: { 26 | opacity: 1, 27 | transition: { 28 | when: 'beforeChildren', 29 | staggerChildren: staggerDelay ? staggerDelay : 0.5, 30 | delay, 31 | ease: 'easeInOut', 32 | staggerDirection: 1, 33 | }, 34 | }, 35 | hidden: { 36 | opacity: 0, 37 | transition: { 38 | when: 'afterChildren', 39 | staggerChildren: staggerDelay ? staggerDelay : 0.5, 40 | staggerDirection: -1, 41 | }, 42 | }, 43 | }; 44 | return ( 45 | 52 | {children} 53 | 54 | ); 55 | }; 56 | 57 | export default FadeLeftToRight; 58 | -------------------------------------------------------------------------------- /src/components/Animations/FadeLeftToRightItem.component.tsx: -------------------------------------------------------------------------------- 1 | // CircleCI doesn't like import { motion } from "framer-motion" here, so we use require 2 | const { motion } = require('framer-motion'); 3 | 4 | import type { IAnimateProps } from './types/Animations.types'; 5 | 6 | /** 7 | * Fade content left to right. Needs to be used with FadeLeftToRight as parent container 8 | * @function FadeLeftToRightItem 9 | * @param {ReactNode} children - Children content to render 10 | * @param {string} cssClass - CSS classes to apply to component 11 | * @returns {JSX.Element} - Rendered component 12 | */ 13 | 14 | const FadeLeftToRightItem = ({ children, cssClass }: IAnimateProps) => { 15 | const FadeLeftToRightItemVariants = { 16 | visible: { opacity: 1, x: 0 }, 17 | hidden: { opacity: 0, x: -20 }, 18 | }; 19 | return ( 20 | 25 | {children} 26 | 27 | ); 28 | }; 29 | 30 | export default FadeLeftToRightItem; 31 | -------------------------------------------------------------------------------- /src/components/Animations/FadeUp.component.tsx: -------------------------------------------------------------------------------- 1 | // CircleCI doesn't like import { motion } from "framer-motion" here, so we use require 2 | const { motion } = require('framer-motion'); 3 | 4 | import type { IAnimateWithDelayProps } from './types/Animations.types'; 5 | 6 | /** 7 | * Fade up content animation 8 | * @function FadeUp 9 | * @param {ReactNode} children - Children content to render 10 | * @param {string} cssClass - CSS classes to apply to component 11 | * @param {number} delay - Time to wait before starting animation 12 | * @returns {JSX.Element} - Rendered component 13 | */ 14 | 15 | const FadeUp = ({ children, cssClass, delay }: IAnimateWithDelayProps) => { 16 | const fadeUpVariants = { 17 | initial: { opacity: 0, y: 20 }, 18 | animate: { 19 | y: 0, 20 | opacity: 1, 21 | transition: { delay, type: 'spring', duration: 0.5, stiffness: 110 }, 22 | }, 23 | }; 24 | return ( 25 | 32 | {children} 33 | 34 | ); 35 | }; 36 | 37 | export default FadeUp; 38 | -------------------------------------------------------------------------------- /src/components/Animations/types/Animations.types.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | export interface IAnimateProps { 4 | children: ReactNode; 5 | cssClass?: string; 6 | } 7 | 8 | export interface IAnimateBounceProps { 9 | children: ReactNode; 10 | cssClass?: string; 11 | viewAmount?: 'some' | 'all' | number; 12 | } 13 | 14 | export interface IAnimateWithDelayProps { 15 | children: ReactNode; 16 | cssClass?: string; 17 | delay: number; 18 | } 19 | 20 | export interface IAnimateStaggerWithDelayProps { 21 | children: ReactNode; 22 | cssClass?: string; 23 | delay: number; 24 | staggerDelay?: number; 25 | animateNotReverse: boolean; 26 | } 27 | -------------------------------------------------------------------------------- /src/components/Cart/CartContents.component.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useMutation, useQuery } from '@apollo/client'; 3 | import Link from 'next/link'; 4 | import Image from 'next/image'; 5 | import { useRouter } from 'next/router'; 6 | import { v4 as uuidv4 } from 'uuid'; 7 | 8 | import { useCartStore } from '@/stores/cartStore'; 9 | import Button from '@/components/UI/Button.component'; 10 | import LoadingSpinner from '../LoadingSpinner/LoadingSpinner.component'; 11 | 12 | import { 13 | getFormattedCart, 14 | getUpdatedItems, 15 | handleQuantityChange, 16 | IProductRootObject, 17 | } from '@/utils/functions/functions'; 18 | 19 | import { GET_CART } from '@/utils/gql/GQL_QUERIES'; 20 | import { UPDATE_CART } from '@/utils/gql/GQL_MUTATIONS'; 21 | 22 | const CartContents = () => { 23 | const router = useRouter(); 24 | const { clearWooCommerceSession, syncWithWooCommerce } = useCartStore(); 25 | const isCheckoutPage = router.pathname === '/kasse'; 26 | 27 | const { data, refetch } = useQuery(GET_CART, { 28 | notifyOnNetworkStatusChange: true, 29 | onCompleted: () => { 30 | const updatedCart = getFormattedCart(data); 31 | if (!updatedCart && !data?.cart?.contents?.nodes?.length) { 32 | clearWooCommerceSession(); 33 | return; 34 | } 35 | if (updatedCart) { 36 | syncWithWooCommerce(updatedCart); 37 | } 38 | }, 39 | }); 40 | 41 | const [updateCart, { loading: updateCartProcessing }] = useMutation( 42 | UPDATE_CART, 43 | { 44 | onCompleted: () => { 45 | refetch(); 46 | setTimeout(() => { 47 | refetch(); 48 | }, 3000); 49 | }, 50 | }, 51 | ); 52 | 53 | const handleRemoveProductClick = ( 54 | cartKey: string, 55 | products: IProductRootObject[], 56 | ) => { 57 | if (products?.length) { 58 | const updatedItems = getUpdatedItems(products, 0, cartKey); 59 | updateCart({ 60 | variables: { 61 | input: { 62 | clientMutationId: uuidv4(), 63 | items: updatedItems, 64 | }, 65 | }, 66 | }); 67 | } 68 | refetch(); 69 | setTimeout(() => { 70 | refetch(); 71 | }, 3000); 72 | }; 73 | 74 | useEffect(() => { 75 | refetch(); 76 | }, [refetch]); 77 | 78 | const cartTotal = data?.cart?.total || '0'; 79 | 80 | const getUnitPrice = (subtotal: string, quantity: number) => { 81 | const numericSubtotal = parseFloat(subtotal.replace(/[^0-9.-]+/g, '')); 82 | return isNaN(numericSubtotal) 83 | ? 'N/A' 84 | : (numericSubtotal / quantity).toFixed(2); 85 | }; 86 | 87 | return ( 88 |
89 | {data?.cart?.contents?.nodes?.length ? ( 90 | <> 91 |
92 | {data.cart.contents.nodes.map((item: IProductRootObject) => ( 93 |
97 |
98 | {item.product.node.name} 107 |
108 |
109 |

110 | {item.product.node.name} 111 |

112 |

113 | kr {getUnitPrice(item.subtotal, item.quantity)} 114 |

115 |
116 |
117 | { 122 | handleQuantityChange( 123 | event, 124 | item.key, 125 | data.cart.contents.nodes, 126 | updateCart, 127 | updateCartProcessing, 128 | ); 129 | }} 130 | className="w-16 px-2 py-1 text-center border border-gray-300 rounded mr-2" 131 | /> 132 | 144 |
145 |
146 |

{item.subtotal}

147 |
148 |
149 | ))} 150 |
151 |
152 |
153 | Subtotal: 154 | {cartTotal} 155 |
156 | {!isCheckoutPage && ( 157 |
158 | 159 | 160 | 161 |
162 | )} 163 |
164 | 165 | ) : ( 166 |
167 |

168 | Ingen produkter i handlekurven 169 |

170 | 171 | 172 | 173 |
174 | )} 175 | {updateCartProcessing && ( 176 |
177 |
178 |

Oppdaterer handlekurv...

179 | 180 |
181 |
182 | )} 183 |
184 | ); 185 | }; 186 | 187 | export default CartContents; 188 | -------------------------------------------------------------------------------- /src/components/Cart/CartInitializer.component.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useQuery } from '@apollo/client'; 3 | 4 | // State 5 | import { useCartStore } from '@/stores/cartStore'; 6 | 7 | // Utils 8 | import { getFormattedCart } from '@/utils/functions/functions'; 9 | 10 | // GraphQL 11 | import { GET_CART } from '@/utils/gql/GQL_QUERIES'; 12 | 13 | /** 14 | * Non-rendering component responsible for initializing the cart state 15 | * by fetching data from WooCommerce and syncing it with the Zustand store. 16 | * This should be rendered once at the application root (_app.tsx). 17 | * @function CartInitializer 18 | * @returns {null} - This component does not render any UI 19 | */ 20 | const CartInitializer = () => { 21 | const { syncWithWooCommerce } = useCartStore(); 22 | 23 | const { data, refetch } = useQuery(GET_CART, { 24 | notifyOnNetworkStatusChange: true, 25 | onCompleted: () => { 26 | // On successful fetch, format the data and sync with the store 27 | const updatedCart = getFormattedCart(data); 28 | if (updatedCart) { 29 | syncWithWooCommerce(updatedCart); 30 | } 31 | }, 32 | // Consider error handling if needed (e.g., onError callback) 33 | }); 34 | 35 | useEffect(() => { 36 | refetch(); 37 | }, [refetch]); 38 | 39 | // This component does not render any UI 40 | return null; 41 | }; 42 | 43 | export default CartInitializer; 44 | -------------------------------------------------------------------------------- /src/components/Category/Categories.component.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import { v4 as uuidv4 } from 'uuid'; 3 | 4 | interface ICategoriesProps { 5 | categories: { id: string; name: string; slug: string }[]; 6 | } 7 | 8 | const Categories = ({ categories }: ICategoriesProps) => ( 9 |
10 |
11 | {categories.map(({ id, name, slug }) => ( 12 | 16 |
17 |
18 |

{name}

19 |
20 |
21 | 22 | ))} 23 |
24 |
25 | ); 26 | 27 | export default Categories; 28 | -------------------------------------------------------------------------------- /src/components/Checkout/Billing.component.tsx: -------------------------------------------------------------------------------- 1 | // Imports 2 | import { 3 | SubmitHandler, 4 | useForm, 5 | useFormContext, 6 | FormProvider, 7 | } from 'react-hook-form'; 8 | 9 | // Components 10 | import { InputField } from '@/components/Input/InputField.component'; 11 | import Button from '../UI/Button.component'; 12 | 13 | // Constants 14 | import { INPUT_FIELDS } from '@/utils/constants/INPUT_FIELDS'; 15 | import { ICheckoutDataProps } from '@/utils/functions/functions'; 16 | 17 | interface IBillingProps { 18 | handleFormSubmit: SubmitHandler; 19 | } 20 | 21 | const OrderButton = () => { 22 | const { register } = useFormContext(); 23 | 24 | return ( 25 |
26 | 33 |
34 | 35 |
36 |
37 | ); 38 | }; 39 | 40 | const Billing = ({ handleFormSubmit }: IBillingProps) => { 41 | const methods = useForm(); 42 | 43 | return ( 44 |
45 | 46 |
47 |
48 | {INPUT_FIELDS.map(({ id, label, name, customValidation }) => ( 49 | 55 | ))} 56 | 57 |
58 |
59 |
60 |
61 | ); 62 | }; 63 | 64 | export default Billing; 65 | -------------------------------------------------------------------------------- /src/components/Checkout/CheckoutForm.component.tsx: -------------------------------------------------------------------------------- 1 | /*eslint complexity: ["error", 20]*/ 2 | // Imports 3 | import { useState, useEffect } from 'react'; 4 | import { useQuery, useMutation, ApolloError } from '@apollo/client'; 5 | 6 | // Components 7 | import Billing from './Billing.component'; 8 | import CartContents from '../Cart/CartContents.component'; 9 | import LoadingSpinner from '../LoadingSpinner/LoadingSpinner.component'; 10 | 11 | // GraphQL 12 | import { GET_CART } from '@/utils/gql/GQL_QUERIES'; 13 | import { CHECKOUT_MUTATION } from '@/utils/gql/GQL_MUTATIONS'; 14 | import { useCartStore } from '@/stores/cartStore'; 15 | 16 | // Utils 17 | import { 18 | getFormattedCart, 19 | createCheckoutData, 20 | ICheckoutDataProps, 21 | } from '@/utils/functions/functions'; 22 | 23 | export interface IBilling { 24 | firstName: string; 25 | lastName: string; 26 | address1: string; 27 | city: string; 28 | postcode: string; 29 | email: string; 30 | phone: string; 31 | } 32 | 33 | export interface IShipping { 34 | firstName: string; 35 | lastName: string; 36 | address1: string; 37 | city: string; 38 | postcode: string; 39 | email: string; 40 | phone: string; 41 | } 42 | 43 | export interface ICheckoutData { 44 | clientMutationId: string; 45 | billing: IBilling; 46 | shipping: IShipping; 47 | shipToDifferentAddress: boolean; 48 | paymentMethod: string; 49 | isPaid: boolean; 50 | transactionId: string; 51 | } 52 | 53 | const CheckoutForm = () => { 54 | const { cart, clearWooCommerceSession, syncWithWooCommerce } = useCartStore(); 55 | const [orderData, setOrderData] = useState(null); 56 | const [requestError, setRequestError] = useState(null); 57 | const [orderCompleted, setorderCompleted] = useState(false); 58 | 59 | // Get cart data query 60 | const { data, refetch } = useQuery(GET_CART, { 61 | notifyOnNetworkStatusChange: true, 62 | onCompleted: () => { 63 | const updatedCart = getFormattedCart(data); 64 | if (!updatedCart && !data?.cart?.contents?.nodes?.length) { 65 | clearWooCommerceSession(); 66 | return; 67 | } 68 | if (updatedCart) { 69 | syncWithWooCommerce(updatedCart); 70 | } 71 | }, 72 | }); 73 | 74 | // Checkout GraphQL mutation 75 | const [checkout, { loading: checkoutLoading }] = useMutation( 76 | CHECKOUT_MUTATION, 77 | { 78 | variables: { 79 | input: orderData, 80 | }, 81 | onCompleted: () => { 82 | clearWooCommerceSession(); 83 | setorderCompleted(true); 84 | refetch(); 85 | }, 86 | onError: (error) => { 87 | setRequestError(error); 88 | refetch(); 89 | }, 90 | }, 91 | ); 92 | 93 | useEffect(() => { 94 | if (null !== orderData) { 95 | // Perform checkout mutation when the value for orderData changes. 96 | checkout(); 97 | setTimeout(() => { 98 | refetch(); 99 | }, 2000); 100 | } 101 | }, [checkout, orderData, refetch]); 102 | 103 | useEffect(() => { 104 | refetch(); 105 | }, [refetch]); 106 | 107 | const handleFormSubmit = (submitData: ICheckoutDataProps) => { 108 | const checkOutData = createCheckoutData(submitData); 109 | 110 | setOrderData(checkOutData); 111 | setRequestError(null); 112 | }; 113 | 114 | return ( 115 | <> 116 | {cart && !orderCompleted ? ( 117 |
118 | {/* Order*/} 119 | 120 | {/*Payment Details*/} 121 | 122 | {/*Error display*/} 123 | {requestError && ( 124 |
125 | En feil har oppstått. 126 |
127 | )} 128 | {/* Checkout Loading*/} 129 | {checkoutLoading && ( 130 |
131 | Behandler ordre, vennligst vent ... 132 | 133 |
134 | )} 135 |
136 | ) : ( 137 | <> 138 | {!cart && !orderCompleted && ( 139 |

140 | Ingen produkter i handlekurven 141 |

142 | )} 143 | {orderCompleted && ( 144 |
145 | Takk for din ordre! 146 |
147 | )} 148 | 149 | )} 150 | 151 | ); 152 | }; 153 | 154 | export default CheckoutForm; 155 | -------------------------------------------------------------------------------- /src/components/Footer/Footer.component.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Renders Footer of the application. 3 | * @function Footer 4 | * @returns {JSX.Element} - Rendered component 5 | */ 6 | const Footer = () => ( 7 |
8 |
9 |
10 |
11 | © {new Date().getFullYear()} Daniel / w3bdesign 12 |
13 |
14 |
15 |
16 | ); 17 | 18 | export default Footer; 19 | -------------------------------------------------------------------------------- /src/components/Footer/Hamburger.component.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback, useRef } from 'react'; 2 | import Link from 'next/link'; 3 | 4 | import FadeLeftToRight from '@/components/Animations/FadeLeftToRight.component'; 5 | import FadeLeftToRightItem from '@/components/Animations/FadeLeftToRightItem.component'; 6 | 7 | import LINKS from '@/utils/constants/LINKS'; 8 | 9 | const hamburgerLine = 10 | 'h-1 w-10 my-1 rounded-full bg-white transition ease transform duration-300 not-sr-only'; 11 | 12 | const opacityFull = 'opacity-100 group-hover:opacity-100'; 13 | 14 | /** 15 | * Hamburger component used in mobile menu. Animates to a X when clicked 16 | * @function Hamburger 17 | * @param {MouseEventHandler} onClick - onClick handler to respond to clicks 18 | * @param {boolean} isExpanded - Should the hamburger animate to a X? 19 | * @returns {JSX.Element} - Rendered component 20 | */ 21 | 22 | const Hamburger = () => { 23 | const [isExpanded, setisExpanded] = useState(false); 24 | const [hidden, setHidden] = useState('invisible'); 25 | const [isAnimating, setIsAnimating] = useState(false); 26 | const animationTimeoutRef = useRef | null>( 27 | null, 28 | ); 29 | 30 | useEffect(() => { 31 | if (isExpanded) { 32 | setHidden(''); 33 | setIsAnimating(true); 34 | 35 | // Clear any existing timeout 36 | if (animationTimeoutRef.current) { 37 | clearTimeout(animationTimeoutRef.current); 38 | } 39 | 40 | // Set a timeout for the animation duration 41 | animationTimeoutRef.current = setTimeout(() => { 42 | setIsAnimating(false); 43 | }, 1000); // Match this with the animation duration 44 | } else { 45 | setIsAnimating(true); 46 | 47 | // Clear any existing timeout 48 | if (animationTimeoutRef.current) { 49 | clearTimeout(animationTimeoutRef.current); 50 | } 51 | 52 | // Set a timeout for the animation duration and hiding 53 | animationTimeoutRef.current = setTimeout(() => { 54 | setHidden('invisible'); 55 | setIsAnimating(false); 56 | }, 1000); // Match this with the animation duration 57 | } 58 | 59 | // Cleanup function to clear timeout when component unmounts 60 | return () => { 61 | if (animationTimeoutRef.current) { 62 | clearTimeout(animationTimeoutRef.current); 63 | } 64 | }; 65 | }, [isExpanded]); 66 | 67 | const handleMobileMenuClick = useCallback(() => { 68 | // Prevent clicks during animation 69 | if (isAnimating) { 70 | return; 71 | } 72 | 73 | /** 74 | * Anti-pattern: setisExpanded(!isExpanded) 75 | * Even if your state updates are batched and multiple updates to the enabled/disabled state are made together 76 | * each update will rely on the correct previous state so that you always end up with the result you expect. 77 | */ 78 | setisExpanded((prevExpanded) => !prevExpanded); 79 | }, [setisExpanded, isAnimating]); 80 | 81 | return ( 82 |
83 | 114 | 119 |
124 |
    125 | {LINKS.map(({ id, title, href }) => ( 126 | 127 |
  • 131 | 132 | { 135 | if (!isAnimating) { 136 | setisExpanded((prevExpanded) => !prevExpanded); 137 | } 138 | }} 139 | onKeyDown={(event) => { 140 | // 'Enter' key or 'Space' key 141 | if ( 142 | (event.key === 'Enter' || event.key === ' ') && 143 | !isAnimating 144 | ) { 145 | setisExpanded((prevExpanded) => !prevExpanded); 146 | } 147 | }} 148 | tabIndex={0} // Make the span focusable 149 | role="button" // Indicate the span acts as a button 150 | > 151 | {title} 152 | 153 | 154 |
  • 155 |
    156 | ))} 157 |
158 |
159 |
160 |
161 | ); 162 | }; 163 | 164 | export default Hamburger; 165 | -------------------------------------------------------------------------------- /src/components/Footer/Stickynav.component.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | import Cart from '@/components/Header/Cart.component'; 4 | import Search from '@/components/AlgoliaSearch/AlgoliaSearchBox.component'; 5 | import SVGMobileSearchIcon from '@/components/SVG/SVGMobileSearchIcon.component'; 6 | 7 | import Hamburger from './Hamburger.component'; 8 | 9 | /** 10 | * Navigation for the application. 11 | * Includes mobile menu. 12 | */ 13 | const Stickynav = () => ( 14 | 45 | ); 46 | 47 | export default Stickynav; 48 | -------------------------------------------------------------------------------- /src/components/Header/Cart.component.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import Link from 'next/link'; 3 | 4 | import { useCartStore } from '@/stores/cartStore'; 5 | 6 | interface ICartProps { 7 | stickyNav?: boolean; 8 | } 9 | 10 | /** 11 | * Displays the shopping cart contents. 12 | * Displays amount of items in cart. 13 | */ 14 | const Cart = ({ stickyNav }: ICartProps) => { 15 | const cart = useCartStore((state) => state.cart); 16 | const [productCount, setProductCount] = useState(); 17 | 18 | useEffect(() => { 19 | if (cart) { 20 | setProductCount(cart.totalProductsCount); 21 | } else { 22 | setProductCount(null); 23 | } 24 | }, [cart]); 25 | 26 | return ( 27 | <> 28 | 29 | 33 | 42 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | {productCount && ( 52 | 56 | {productCount} 57 | 58 | )} 59 | 60 | ); 61 | }; 62 | 63 | export default Cart; 64 | -------------------------------------------------------------------------------- /src/components/Header/Header.component.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | 3 | import Navbar from './Navbar.component'; 4 | 5 | interface IHeaderProps { 6 | title: string; 7 | } 8 | 9 | /** 10 | * Renders header for each page. 11 | * @function Header 12 | * @param {string} title - Title for the page. Is set in {title} 13 | * @returns {JSX.Element} - Rendered component 14 | */ 15 | 16 | const Header = ({ title }: IHeaderProps) => ( 17 | <> 18 | 19 | {`Next.js webshop with WooCommerce ${title}`} 20 | 21 | 22 | 27 | 28 |
29 | 30 |
31 | 32 | ); 33 | 34 | export default Header; 35 | -------------------------------------------------------------------------------- /src/components/Header/Navbar.component.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | 3 | import Cart from './Cart.component'; 4 | import AlgoliaSearchBox from '../AlgoliaSearch/AlgoliaSearchBox.component'; 5 | import MobileSearch from '../AlgoliaSearch/MobileSearch.component'; 6 | 7 | /** 8 | * Navigation for the application. 9 | * Includes mobile menu. 10 | */ 11 | const Navbar = () => { 12 | return ( 13 |
14 | 59 |
60 | ); 61 | }; 62 | 63 | export default Navbar; 64 | -------------------------------------------------------------------------------- /src/components/Index/Hero.component.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import Button from '../UI/Button.component'; 3 | 4 | /** 5 | * Renders Hero section for Index page 6 | * @function Hero 7 | * @returns {JSX.Element} - Rendered component 8 | */ 9 | const Hero = () => ( 10 |
11 |
12 | Hero image 20 |
21 |
22 | 23 |
24 |
25 |

26 | Stripete Zig Zag Pute Sett 27 |

28 | 34 |
35 |
36 |
37 | ); 38 | 39 | export default Hero; 40 | -------------------------------------------------------------------------------- /src/components/Input/InputField.component.tsx: -------------------------------------------------------------------------------- 1 | import { FieldValues, useFormContext, UseFormRegister } from 'react-hook-form'; 2 | 3 | interface ICustomValidation { 4 | required?: boolean; 5 | minlength?: number; 6 | } 7 | 8 | interface IErrors {} 9 | 10 | export interface IInputRootObject { 11 | inputLabel: string; 12 | inputName: string; 13 | customValidation: ICustomValidation; 14 | errors?: IErrors; 15 | register?: UseFormRegister; 16 | type?: string; 17 | } 18 | 19 | /** 20 | * Input field component displays a text input in a form, with label. 21 | * The various properties of the input field can be determined with the props: 22 | * @param {ICustomValidation} [customValidation] - the validation rules to apply to the input field 23 | * @param {IErrors} errors - the form errors object provided by react-hook-form 24 | * @param {string} inputLabel - used for the display label 25 | * @param {string} inputName - the key of the value in the submitted data. Must be unique 26 | * @param {UseFormRegister} register - register function from react-hook-form 27 | * @param {boolean} [required=true] - whether or not this field is required. default true 28 | * @param {string} [type='text'] - the input type. defaults to text 29 | */ 30 | export const InputField = ({ 31 | customValidation, 32 | inputLabel, 33 | inputName, 34 | type, 35 | }: IInputRootObject) => { 36 | const { register } = useFormContext(); 37 | 38 | return ( 39 |
40 | 43 | 51 |
52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /src/components/Layout/Layout.component.tsx: -------------------------------------------------------------------------------- 1 | // Imports 2 | import { ReactNode } from 'react'; 3 | 4 | // Components 5 | import Header from '@/components/Header/Header.component'; 6 | import PageTitle from './PageTitle.component'; 7 | import Footer from '@/components/Footer/Footer.component'; 8 | import Stickynav from '@/components/Footer/Stickynav.component'; 9 | 10 | interface ILayoutProps { 11 | children?: ReactNode; 12 | title: string; 13 | } 14 | 15 | /** 16 | * Renders layout for each page. Also passes along the title to the Header component. 17 | * @function Layout 18 | * @param {ReactNode} children - Children to be rendered by Layout component 19 | * @param {TTitle} title - Title for the page. Is set in {title} 20 | * @returns {JSX.Element} - Rendered component 21 | */ 22 | 23 | const Layout = ({ children, title }: ILayoutProps) => { 24 | return ( 25 |
26 |
27 | {title === 'Hjem' ? ( 28 |
{children}
29 | ) : ( 30 |
31 | 32 |
{children}
33 |
34 | )} 35 |
36 |
37 | 38 |
39 |
40 | ); 41 | }; 42 | 43 | export default Layout; 44 | -------------------------------------------------------------------------------- /src/components/Layout/PageTitle.component.tsx: -------------------------------------------------------------------------------- 1 | interface IPageTitleProps { 2 | title: string; 3 | } 4 | 5 | /** 6 | * Renders page title for each page. 7 | * @function PageTitle 8 | * @param {string} title - Title for the page. Is set in {title} 9 | * @returns {JSX.Element} - Rendered component 10 | */ 11 | const PageTitle = ({ title }: IPageTitleProps) => ( 12 |
13 |
14 |

15 | {title} 16 |

17 |
18 |
19 | ); 20 | 21 | export default PageTitle; 22 | -------------------------------------------------------------------------------- /src/components/LoadingSpinner/LoadingSpinner.component.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Loading spinner, shows while loading products or categories. 3 | * Uses Styled-Components 4 | */ 5 | const LoadingSpinner = () => ( 6 |
7 |
8 | 24 | Laster ... 25 |
26 |
27 | ); 28 | 29 | export default LoadingSpinner; 30 | -------------------------------------------------------------------------------- /src/components/Product/AddToCart.component.tsx: -------------------------------------------------------------------------------- 1 | // Imports 2 | import { useState } from 'react'; 3 | import { useQuery, useMutation } from '@apollo/client'; 4 | import { v4 as uuidv4 } from 'uuid'; 5 | 6 | // Components 7 | import Button from '@/components/UI/Button.component'; 8 | 9 | // State 10 | import { useCartStore } from '@/stores/cartStore'; 11 | 12 | // Utils 13 | import { getFormattedCart } from '@/utils/functions/functions'; 14 | 15 | // GraphQL 16 | import { GET_CART } from '@/utils/gql/GQL_QUERIES'; 17 | import { ADD_TO_CART } from '@/utils/gql/GQL_MUTATIONS'; 18 | 19 | interface IImage { 20 | __typename: string; 21 | id: string; 22 | uri: string; 23 | title: string; 24 | srcSet: string; 25 | sourceUrl: string; 26 | } 27 | 28 | interface IVariationNode { 29 | __typename: string; 30 | name: string; 31 | } 32 | 33 | interface IAllPaColors { 34 | __typename: string; 35 | nodes: IVariationNode[]; 36 | } 37 | 38 | interface IAllPaSizes { 39 | __typename: string; 40 | nodes: IVariationNode[]; 41 | } 42 | 43 | export interface IVariationNodes { 44 | __typename: string; 45 | id: string; 46 | databaseId: number; 47 | name: string; 48 | stockStatus: string; 49 | stockQuantity: number; 50 | purchasable: boolean; 51 | onSale: boolean; 52 | salePrice?: string; 53 | regularPrice: string; 54 | } 55 | 56 | interface IVariations { 57 | __typename: string; 58 | nodes: IVariationNodes[]; 59 | } 60 | 61 | export interface IProduct { 62 | __typename: string; 63 | id: string; 64 | databaseId: number; 65 | averageRating: number; 66 | slug: string; 67 | description: string; 68 | onSale: boolean; 69 | image: IImage; 70 | name: string; 71 | salePrice?: string; 72 | regularPrice: string; 73 | price: string; 74 | stockQuantity: number; 75 | allPaColors?: IAllPaColors; 76 | allPaSizes?: IAllPaSizes; 77 | variations?: IVariations; 78 | } 79 | 80 | export interface IProductRootObject { 81 | product: IProduct; 82 | variationId?: number; 83 | fullWidth?: boolean; 84 | } 85 | 86 | /** 87 | * Handles the Add to cart functionality. 88 | * Uses GraphQL for product data 89 | * @param {IAddToCartProps} product // Product data 90 | * @param {number} variationId // Variation ID 91 | * @param {boolean} fullWidth // Whether the button should be full-width 92 | */ 93 | 94 | const AddToCart = ({ 95 | product, 96 | variationId, 97 | fullWidth = false, 98 | }: IProductRootObject) => { 99 | const { syncWithWooCommerce, isLoading: isCartLoading } = useCartStore(); 100 | const [requestError, setRequestError] = useState(false); 101 | 102 | const productId = product?.databaseId ? product?.databaseId : variationId; 103 | 104 | const productQueryInput = { 105 | clientMutationId: uuidv4(), // Generate a unique id. 106 | productId, 107 | }; 108 | 109 | // Get cart data query 110 | const { data, refetch } = useQuery(GET_CART, { 111 | notifyOnNetworkStatusChange: true, 112 | onCompleted: () => { 113 | const updatedCart = getFormattedCart(data); 114 | if (updatedCart) { 115 | syncWithWooCommerce(updatedCart); 116 | } 117 | }, 118 | }); 119 | 120 | // Add to cart mutation 121 | const [addToCart, { loading: addToCartLoading }] = useMutation(ADD_TO_CART, { 122 | variables: { 123 | input: productQueryInput, 124 | }, 125 | 126 | onCompleted: () => { 127 | // Update the cart with new values in React context. 128 | refetch(); 129 | }, 130 | 131 | onError: () => { 132 | setRequestError(true); 133 | }, 134 | }); 135 | 136 | const handleAddToCart = () => { 137 | addToCart(); 138 | // Refetch cart after 2 seconds 139 | setTimeout(() => { 140 | refetch(); 141 | }, 2000); 142 | }; 143 | 144 | return ( 145 | <> 146 | 153 | 154 | ); 155 | }; 156 | 157 | export default AddToCart; 158 | -------------------------------------------------------------------------------- /src/components/Product/DisplayProducts.component.tsx: -------------------------------------------------------------------------------- 1 | /*eslint complexity: ["error", 20]*/ 2 | import Link from 'next/link'; 3 | import { v4 as uuidv4 } from 'uuid'; 4 | 5 | import { filteredVariantPrice, paddedPrice } from '@/utils/functions/functions'; 6 | 7 | interface Image { 8 | __typename: string; 9 | sourceUrl?: string; 10 | } 11 | 12 | interface Node { 13 | __typename: string; 14 | price: string; 15 | regularPrice: string; 16 | salePrice?: string; 17 | } 18 | 19 | interface Variations { 20 | __typename: string; 21 | nodes: Node[]; 22 | } 23 | 24 | interface RootObject { 25 | __typename: string; 26 | name: string; 27 | onSale: boolean; 28 | slug: string; 29 | image: Image; 30 | price: string; 31 | regularPrice: string; 32 | salePrice?: string; 33 | variations: Variations; 34 | } 35 | 36 | interface IDisplayProductsProps { 37 | products: RootObject[]; 38 | } 39 | 40 | /** 41 | * Displays all of the products as long as length is defined. 42 | * Does a map() over the props array and utilizes uuidv4 for unique key values. 43 | * @function DisplayProducts 44 | * @param {IDisplayProductsProps} products Products to render 45 | * @returns {JSX.Element} - Rendered component 46 | */ 47 | 48 | const DisplayProducts = ({ products }: IDisplayProductsProps) => ( 49 |
50 |
54 | {products ? ( 55 | products.map( 56 | ({ 57 | name, 58 | price, 59 | regularPrice, 60 | salePrice, 61 | onSale, 62 | slug, 63 | image, 64 | variations, 65 | }) => { 66 | // Add padding/empty character after currency symbol here 67 | if (price) { 68 | price = paddedPrice(price, 'kr'); 69 | } 70 | if (regularPrice) { 71 | regularPrice = paddedPrice(regularPrice, 'kr'); 72 | } 73 | if (salePrice) { 74 | salePrice = paddedPrice(salePrice, 'kr'); 75 | } 76 | 77 | return ( 78 |
79 | 80 |
81 | {image ? ( 82 | {name} 88 | ) : ( 89 | {name} 97 | )} 98 |
99 | 100 | 101 | 102 |
103 |

104 | {name} 105 |

106 |
107 |
108 | 109 |
110 | {onSale ? ( 111 |
112 | 113 | {variations && filteredVariantPrice(price, '')} 114 | {!variations && salePrice} 115 | 116 | 117 | {variations && filteredVariantPrice(price, 'right')} 118 | {!variations && regularPrice} 119 | 120 |
121 | ) : ( 122 | {price} 123 | )} 124 |
125 |
126 | ); 127 | }, 128 | ) 129 | ) : ( 130 |
131 | Ingen produkter funnet 132 |
133 | )} 134 |
135 |
136 | ); 137 | 138 | export default DisplayProducts; 139 | -------------------------------------------------------------------------------- /src/components/Product/ProductCard.component.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import Image from 'next/image'; 3 | import { paddedPrice } from '@/utils/functions/functions'; 4 | 5 | interface ProductCardProps { 6 | databaseId: number; 7 | name: string; 8 | price: string; 9 | regularPrice: string; 10 | salePrice?: string; 11 | onSale: boolean; 12 | slug: string; 13 | image?: { 14 | sourceUrl?: string; 15 | }; 16 | } 17 | 18 | const ProductCard = ({ 19 | databaseId, 20 | name, 21 | price, 22 | regularPrice, 23 | salePrice, 24 | onSale, 25 | slug, 26 | image, 27 | }: ProductCardProps) => { 28 | // Add padding/empty character after currency symbol 29 | const formattedPrice = price ? paddedPrice(price, 'kr') : price; 30 | const formattedRegularPrice = regularPrice ? paddedPrice(regularPrice, 'kr') : regularPrice; 31 | const formattedSalePrice = salePrice ? paddedPrice(salePrice, 'kr') : salePrice; 32 | 33 | return ( 34 |
35 |
36 | 37 | {image?.sourceUrl ? ( 38 | {name} 46 | ) : ( 47 |
48 | No image 49 |
50 | )} 51 | 52 |
53 | 54 | 55 |
56 |

57 | {name} 58 |

59 |
60 | 61 |
62 | {onSale ? ( 63 |
64 | {formattedSalePrice} 65 | {formattedRegularPrice} 66 |
67 | ) : ( 68 | {formattedPrice} 69 | )} 70 |
71 |
72 | ); 73 | }; 74 | 75 | export default ProductCard; 76 | -------------------------------------------------------------------------------- /src/components/Product/ProductFilters.component.tsx: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction } from 'react'; 2 | import { Product, ProductType } from '@/types/product'; 3 | import Button from '@/components/UI/Button.component'; 4 | import Checkbox from '@/components/UI/Checkbox.component'; 5 | import RangeSlider from '@/components/UI/RangeSlider.component'; 6 | 7 | interface ProductFiltersProps { 8 | selectedSizes: string[]; 9 | setSelectedSizes: Dispatch>; 10 | selectedColors: string[]; 11 | setSelectedColors: Dispatch>; 12 | priceRange: [number, number]; 13 | setPriceRange: Dispatch>; 14 | productTypes: ProductType[]; 15 | toggleProductType: (id: string) => void; 16 | products: Product[]; 17 | resetFilters: () => void; 18 | } 19 | 20 | const ProductFilters = ({ 21 | selectedSizes, 22 | setSelectedSizes, 23 | selectedColors, 24 | setSelectedColors, 25 | priceRange, 26 | setPriceRange, 27 | productTypes, 28 | toggleProductType, 29 | products, 30 | resetFilters, 31 | }: ProductFiltersProps) => { 32 | // Get unique sizes from all products 33 | const sizes = Array.from( 34 | new Set( 35 | products.flatMap( 36 | (product: Product) => 37 | product.allPaSizes?.nodes.map( 38 | (node: { name: string }) => node.name, 39 | ) || [], 40 | ), 41 | ), 42 | ).sort((a, b) => a.localeCompare(b)); 43 | 44 | // Get unique colors from all products 45 | const availableColors = products 46 | .flatMap((product: Product) => product.allPaColors?.nodes || []) 47 | .filter((color, index, self) => 48 | index === self.findIndex((c) => c.slug === color.slug) 49 | ) 50 | .sort((a, b) => a.name.localeCompare(b.name)); 51 | 52 | const colors = availableColors.map((color) => ({ 53 | name: color.name, 54 | class: `bg-${color.slug}-500` 55 | })); 56 | 57 | const toggleSize = (size: string) => { 58 | setSelectedSizes((prev) => 59 | prev.includes(size) ? prev.filter((s) => s !== size) : [...prev, size], 60 | ); 61 | }; 62 | 63 | const toggleColor = (color: string) => { 64 | setSelectedColors((prev) => 65 | prev.includes(color) ? prev.filter((c) => c !== color) : [...prev, color], 66 | ); 67 | }; 68 | 69 | return ( 70 |
71 |
72 |
73 |

PRODUKT TYPE

74 |
75 | {productTypes.map((type) => ( 76 | toggleProductType(type.id)} 82 | /> 83 | ))} 84 |
85 |
86 | 87 |
88 |

PRIS

89 | setPriceRange([priceRange[0], value])} 97 | formatValue={(value) => `kr ${value}`} 98 | /> 99 |
100 | 101 |
102 |

STØRRELSE

103 |
104 | {sizes.map((size) => ( 105 | 113 | ))} 114 |
115 |
116 | 117 |
118 |

FARGE

119 |
120 | {colors.map((color) => ( 121 |
135 |
136 | 137 | 143 |
144 |
145 | ); 146 | }; 147 | 148 | export default ProductFilters; 149 | -------------------------------------------------------------------------------- /src/components/Product/ProductList.component.tsx: -------------------------------------------------------------------------------- 1 | import { Product } from '@/types/product'; 2 | import { useProductFilters } from '@/hooks/useProductFilters'; 3 | import ProductCard from './ProductCard.component'; 4 | import ProductFilters from './ProductFilters.component'; 5 | 6 | interface ProductListProps { 7 | products: Product[]; 8 | title: string; 9 | } 10 | 11 | const ProductList = ({ products, title }: ProductListProps) => { 12 | const { 13 | sortBy, 14 | setSortBy, 15 | selectedSizes, 16 | setSelectedSizes, 17 | selectedColors, 18 | setSelectedColors, 19 | priceRange, 20 | setPriceRange, 21 | productTypes, 22 | toggleProductType, 23 | resetFilters, 24 | filterProducts 25 | } = useProductFilters(products); 26 | 27 | const filteredProducts = filterProducts(products); 28 | 29 | return ( 30 |
31 | 43 | 44 | {/* Main Content */} 45 |
46 |
47 |

48 | {title} ({filteredProducts.length}) 49 |

50 | 51 |
52 | 53 | 64 |
65 |
66 | 67 |
68 | {filteredProducts.map((product: Product) => ( 69 | 80 | ))} 81 |
82 |
83 |
84 | ); 85 | }; 86 | 87 | export default ProductList; 88 | -------------------------------------------------------------------------------- /src/components/Product/SingleProduct.component.tsx: -------------------------------------------------------------------------------- 1 | // Imports 2 | import { useState, useEffect } from 'react'; 3 | 4 | // Utils 5 | import { filteredVariantPrice, paddedPrice } from '@/utils/functions/functions'; 6 | 7 | // Components 8 | import AddToCart, { IProductRootObject } from './AddToCart.component'; 9 | import LoadingSpinner from '@/components/LoadingSpinner/LoadingSpinner.component'; 10 | 11 | const SingleProduct = ({ product }: IProductRootObject) => { 12 | const [isLoading, setIsLoading] = useState(true); 13 | const [selectedVariation, setSelectedVariation] = useState(); 14 | 15 | const placeholderFallBack = 'https://via.placeholder.com/600'; 16 | 17 | let DESCRIPTION_WITHOUT_HTML; 18 | 19 | useEffect(() => { 20 | setIsLoading(false); 21 | if (product.variations) { 22 | const firstVariant = product.variations.nodes[0].databaseId; 23 | setSelectedVariation(firstVariant); 24 | } 25 | }, [product.variations]); 26 | 27 | let { description, image, name, onSale, price, regularPrice, salePrice } = 28 | product; 29 | 30 | // Add padding/empty character after currency symbol here 31 | if (price) { 32 | price = paddedPrice(price, 'kr'); 33 | } 34 | if (regularPrice) { 35 | regularPrice = paddedPrice(regularPrice, 'kr'); 36 | } 37 | if (salePrice) { 38 | salePrice = paddedPrice(salePrice, 'kr'); 39 | } 40 | 41 | // Strip out HTML from description 42 | if (process.browser) { 43 | DESCRIPTION_WITHOUT_HTML = new DOMParser().parseFromString( 44 | description, 45 | 'text/html', 46 | ).body.textContent; 47 | } 48 | 49 | return ( 50 |
51 | {isLoading ? ( 52 |
53 |

Laster produkt ...

54 |
55 | 56 |
57 | ) : ( 58 |
59 |
60 | {/* Image Container */} 61 |
62 |
63 | {name} 73 |
74 |
75 | 76 | {/* Product Details Container */} 77 |
78 |

79 | {name} 80 |

81 | 82 | {/* Price Display */} 83 |
84 | {onSale ? ( 85 |
86 |

87 | {product.variations 88 | ? filteredVariantPrice(price, '') 89 | : salePrice} 90 |

91 |

92 | {product.variations 93 | ? filteredVariantPrice(price, 'right') 94 | : regularPrice} 95 |

96 |
97 | ) : ( 98 |

{price}

99 | )} 100 |
101 | 102 | {/* Description */} 103 |

104 | {DESCRIPTION_WITHOUT_HTML} 105 |

106 | 107 | {/* Stock Status */} 108 | {Boolean(product.stockQuantity) && ( 109 |
110 |
111 |

112 | {product.stockQuantity} på lager 113 |

114 |
115 |
116 | )} 117 | 118 | {/* Variations Select */} 119 | {product.variations && ( 120 |
121 | 127 | 143 |
144 | )} 145 | 146 | {/* Add to Cart Button */} 147 |
148 | {product.variations ? ( 149 | 154 | ) : ( 155 | 156 | )} 157 |
158 |
159 |
160 |
161 | )} 162 |
163 | ); 164 | }; 165 | 166 | export default SingleProduct; 167 | -------------------------------------------------------------------------------- /src/components/SVG/SVGMobileSearchIcon.component.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * The SVG that we use for search in the navbar for mobile. 3 | * Also includes logic for closing and opening the search form. 4 | */ 5 | const SVGMobileSearchIcon = () => { 6 | const scrollToTop = () => { 7 | window.scrollTo({ 8 | top: 0, 9 | behavior: 'smooth', 10 | }); 11 | }; 12 | 13 | return ( 14 |
15 | 24 | 25 | 26 |
27 | ); 28 | }; 29 | 30 | export default SVGMobileSearchIcon; 31 | -------------------------------------------------------------------------------- /src/components/UI/Button.component.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import Link from 'next/link'; 3 | 4 | type TButtonVariant = 'primary' | 'secondary' | 'hero' | 'filter' | 'reset'; 5 | 6 | interface IButtonProps { 7 | handleButtonClick?: () => void; 8 | buttonDisabled?: boolean; 9 | variant?: TButtonVariant; 10 | children?: ReactNode; 11 | fullWidth?: boolean; 12 | href?: string; 13 | title?: string; 14 | selected?: boolean; 15 | } 16 | 17 | /** 18 | * Renders a clickable button 19 | * @function Button 20 | * @param {void} handleButtonClick - Handle button click 21 | * @param {boolean?} buttonDisabled - Is button disabled? 22 | * @param {TButtonVariant?} variant - Button variant 23 | * @param {ReactNode} children - Children for button 24 | * @param {boolean?} fullWidth - Whether the button should be full-width on mobile 25 | * @param {boolean?} selected - Whether the button is in a selected state 26 | * @returns {JSX.Element} - Rendered component 27 | */ 28 | const Button = ({ 29 | handleButtonClick, 30 | buttonDisabled, 31 | variant = 'primary', 32 | children, 33 | fullWidth = false, 34 | href, 35 | title, 36 | selected = false, 37 | }: IButtonProps) => { 38 | const getVariantClasses = (variant: TButtonVariant = 'primary') => { 39 | switch (variant) { 40 | case 'hero': 41 | return 'inline-block px-8 py-4 text-sm tracking-wider uppercase bg-white text-gray-900 hover:bg-gray-400 hover:text-white hover:shadow-md'; 42 | case 'filter': 43 | return selected 44 | ? 'px-3 py-1 border rounded bg-gray-900 text-white' 45 | : 'px-3 py-1 border rounded hover:bg-gray-100 bg-white text-gray-900'; 46 | case 'reset': 47 | return 'w-full mt-8 py-2 px-4 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors'; 48 | case 'secondary': 49 | return 'px-2 lg:px-4 py-2 font-bold border border-gray-400 border-solid rounded text-white bg-red-500 hover:bg-red-600'; 50 | default: // primary 51 | return 'px-2 lg:px-4 py-2 font-bold border border-gray-400 border-solid rounded text-white bg-blue-500 hover:bg-blue-600'; 52 | } 53 | }; 54 | 55 | const classes = `${getVariantClasses(variant)} ease-in-out transition-all duration-300 disabled:opacity-50 ${ 56 | fullWidth ? 'w-full md:w-auto' : '' 57 | }`; 58 | 59 | if (href) { 60 | return ( 61 | 62 | {children} 63 | 64 | ); 65 | } 66 | 67 | return ( 68 | 76 | ); 77 | }; 78 | 79 | export default Button; 80 | -------------------------------------------------------------------------------- /src/components/UI/Checkbox.component.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent } from 'react'; 2 | 3 | interface ICheckboxProps { 4 | id: string; 5 | label: string; 6 | checked: boolean; 7 | onChange: (e: ChangeEvent) => void; 8 | } 9 | 10 | /** 11 | * A reusable checkbox component with a label 12 | * @function Checkbox 13 | * @param {string} id - Unique identifier for the checkbox 14 | * @param {string} label - Label text to display next to the checkbox 15 | * @param {boolean} checked - Whether the checkbox is checked 16 | * @param {function} onChange - Handler for when the checkbox state changes 17 | * @returns {JSX.Element} - Rendered component 18 | */ 19 | const Checkbox = ({ id, label, checked, onChange }: ICheckboxProps) => { 20 | return ( 21 | 31 | ); 32 | }; 33 | 34 | export default Checkbox; 35 | -------------------------------------------------------------------------------- /src/components/UI/RangeSlider.component.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent } from 'react'; 2 | 3 | interface IRangeSliderProps { 4 | id: string; 5 | label: string; 6 | min: number; 7 | max: number; 8 | value: number; 9 | onChange: (value: number) => void; 10 | startValue?: number; 11 | formatValue?: (value: number) => string; 12 | } 13 | 14 | /** 15 | * A reusable range slider component with labels 16 | * @function RangeSlider 17 | * @param {string} id - Unique identifier for the slider 18 | * @param {string} label - Accessible label for the slider 19 | * @param {number} min - Minimum value of the range 20 | * @param {number} max - Maximum value of the range 21 | * @param {number} value - Current value of the slider 22 | * @param {function} onChange - Handler for when the slider value changes 23 | * @param {number} startValue - Optional starting value to display (defaults to min) 24 | * @param {function} formatValue - Optional function to format the displayed values 25 | * @returns {JSX.Element} - Rendered component 26 | */ 27 | const RangeSlider = ({ 28 | id, 29 | label, 30 | min, 31 | max, 32 | value, 33 | onChange, 34 | startValue = min, 35 | formatValue = (val: number) => val.toString(), 36 | }: IRangeSliderProps) => { 37 | const handleChange = (e: ChangeEvent) => { 38 | onChange(parseInt(e.target.value)); 39 | }; 40 | 41 | return ( 42 |
43 | 46 | 55 |
56 | {formatValue(startValue)} 57 | {formatValue(value)} 58 |
59 |
60 | ); 61 | }; 62 | 63 | export default RangeSlider; 64 | -------------------------------------------------------------------------------- /src/components/User/UserRegistration.component.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { useMutation } from '@apollo/client'; 3 | import { useForm, FormProvider } from 'react-hook-form'; 4 | import { CREATE_USER } from '../../utils/gql/GQL_MUTATIONS'; 5 | import { InputField } from '../Input/InputField.component'; 6 | import LoadingSpinner from '../LoadingSpinner/LoadingSpinner.component'; 7 | import Button from '../UI/Button.component'; 8 | 9 | interface IRegistrationData { 10 | username: string; 11 | email: string; 12 | password: string; 13 | firstName: string; 14 | lastName: string; 15 | } 16 | 17 | /** 18 | * User registration component that handles WooCommerce customer creation 19 | * @function UserRegistration 20 | * @returns {JSX.Element} - Rendered component with registration form 21 | */ 22 | const UserRegistration = () => { 23 | const methods = useForm(); 24 | const [registerUser, { loading, error }] = useMutation(CREATE_USER); 25 | const [registrationCompleted, setRegistrationCompleted] = useState(false); 26 | 27 | const onSubmit = async (data: IRegistrationData) => { 28 | try { 29 | const response = await registerUser({ 30 | variables: data, 31 | }); 32 | 33 | const customer = response.data?.registerCustomer?.customer; 34 | if (customer) { 35 | setRegistrationCompleted(true); 36 | } else { 37 | throw new Error('Failed to register customer'); 38 | } 39 | } catch (error: unknown) { 40 | console.error('Registration error:', error); 41 | } 42 | }; 43 | 44 | if (registrationCompleted) { 45 | return ( 46 |
47 |

48 | Registrering vellykket! 49 |

50 |

Du kan nå logge inn med din konto.

51 |
52 | ); 53 | } 54 | 55 | return ( 56 |
57 | 58 |
59 |
60 | 66 | 72 | 78 | 84 | 90 | 91 | {error && ( 92 |
93 | {error.message} 94 |
95 | )} 96 | 97 |
98 |
99 | 102 |
103 |
104 |
105 |
106 |
107 |
108 | ); 109 | }; 110 | 111 | export default UserRegistration; 112 | -------------------------------------------------------------------------------- /src/hooks/useProductFilters.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { Product, ProductType } from '@/types/product'; 3 | import { getUniqueProductTypes } from '@/utils/functions/productUtils'; 4 | 5 | export const useProductFilters = (products: Product[]) => { 6 | const [sortBy, setSortBy] = useState('popular'); 7 | const [selectedSizes, setSelectedSizes] = useState([]); 8 | const [selectedColors, setSelectedColors] = useState([]); 9 | const [priceRange, setPriceRange] = useState<[number, number]>([0, 1000]); 10 | const [productTypes, setProductTypes] = useState(() => 11 | products ? getUniqueProductTypes(products) : [], 12 | ); 13 | 14 | const toggleProductType = (id: string) => { 15 | setProductTypes((prev) => 16 | prev.map((type) => 17 | type.id === id ? { ...type, checked: !type.checked } : type, 18 | ), 19 | ); 20 | }; 21 | 22 | const resetFilters = () => { 23 | setSelectedSizes([]); 24 | setSelectedColors([]); 25 | setPriceRange([0, 1000]); 26 | setProductTypes((prev) => 27 | prev.map((type) => ({ ...type, checked: false })), 28 | ); 29 | }; 30 | 31 | const filterProducts = (products: Product[]) => { 32 | const filtered = products?.filter((product: Product) => { 33 | // Filter by price 34 | const productPrice = parseFloat(product.price.replace(/[^0-9.]/g, '')); 35 | const withinPriceRange = 36 | productPrice >= priceRange[0] && productPrice <= priceRange[1]; 37 | if (!withinPriceRange) return false; 38 | 39 | // Filter by product type 40 | const selectedTypes = productTypes 41 | .filter((t) => t.checked) 42 | .map((t) => t.name.toLowerCase()); 43 | if (selectedTypes.length > 0) { 44 | const productCategories = 45 | product.productCategories?.nodes.map((cat) => 46 | cat.name.toLowerCase(), 47 | ) || []; 48 | if (!selectedTypes.some((type) => productCategories.includes(type))) 49 | return false; 50 | } 51 | 52 | // Filter by size 53 | if (selectedSizes.length > 0) { 54 | const productSizes = 55 | product.allPaSizes?.nodes.map((node) => node.name) || []; 56 | if (!selectedSizes.some((size) => productSizes.includes(size))) 57 | return false; 58 | } 59 | 60 | // Filter by color 61 | if (selectedColors.length > 0) { 62 | const productColors = 63 | product.allPaColors?.nodes.map((node) => node.name) || []; 64 | if (!selectedColors.some((color) => productColors.includes(color))) 65 | return false; 66 | } 67 | 68 | return true; 69 | }); 70 | 71 | // Sort products 72 | return [...(filtered || [])].sort((a, b) => { 73 | const priceA = parseFloat(a.price.replace(/[^0-9.]/g, '')); 74 | const priceB = parseFloat(b.price.replace(/[^0-9.]/g, '')); 75 | 76 | switch (sortBy) { 77 | case 'price-low': 78 | return priceA - priceB; 79 | case 'price-high': 80 | return priceB - priceA; 81 | case 'newest': 82 | return b.databaseId - a.databaseId; 83 | default: // 'popular' 84 | return 0; 85 | } 86 | }); 87 | }; 88 | 89 | return { 90 | sortBy, 91 | setSortBy, 92 | selectedSizes, 93 | setSelectedSizes, 94 | selectedColors, 95 | setSelectedColors, 96 | priceRange, 97 | setPriceRange, 98 | productTypes, 99 | toggleProductType, 100 | resetFilters, 101 | filterProducts, 102 | }; 103 | }; 104 | -------------------------------------------------------------------------------- /src/images/hero.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/w3bdesign/nextjs-woocommerce/603068a80b9ad9656812a84137e21f2ffec005bb/src/images/hero.jpg -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | // Imports 2 | import Router from 'next/router'; 3 | import NProgress from 'nprogress'; 4 | import { ApolloProvider } from '@apollo/client'; 5 | 6 | import client from '@/utils/apollo/ApolloClient'; 7 | import CartInitializer from '@/components/Cart/CartInitializer.component'; 8 | 9 | // Types 10 | import type { AppProps } from 'next/app'; 11 | 12 | // Styles 13 | import '@/styles/globals.css'; 14 | import 'nprogress/nprogress.css'; 15 | 16 | // NProgress 17 | Router.events.on('routeChangeStart', () => NProgress.start()); 18 | Router.events.on('routeChangeComplete', () => NProgress.done()); 19 | Router.events.on('routeChangeError', () => NProgress.done()); 20 | 21 | function MyApp({ Component, pageProps }: AppProps) { 22 | return ( 23 | 24 | 25 | 26 | 27 | ); 28 | } 29 | 30 | export default MyApp; 31 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from 'next/document'; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/pages/handlekurv.tsx: -------------------------------------------------------------------------------- 1 | // Components 2 | import Layout from '@/components/Layout/Layout.component'; 3 | import CartContents from '@/components/Cart/CartContents.component'; 4 | 5 | // Types 6 | import type { NextPage } from 'next'; 7 | 8 | const Handlekurv: NextPage = () => ( 9 | 10 | 11 | 12 | ); 13 | 14 | export default Handlekurv; 15 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | // Components 2 | import Hero from '@/components/Index/Hero.component'; 3 | import DisplayProducts from '@/components/Product/DisplayProducts.component'; 4 | import Layout from '@/components/Layout/Layout.component'; 5 | 6 | // Utilities 7 | import client from '@/utils/apollo/ApolloClient'; 8 | 9 | // Types 10 | import type { NextPage, GetStaticProps, InferGetStaticPropsType } from 'next'; 11 | 12 | // GraphQL 13 | import { FETCH_ALL_PRODUCTS_QUERY } from '@/utils/gql/GQL_QUERIES'; 14 | 15 | /** 16 | * Main index page 17 | * @function Index 18 | * @param {InferGetStaticPropsType} products 19 | * @returns {JSX.Element} - Rendered component 20 | */ 21 | 22 | const Index: NextPage = ({ 23 | products, 24 | }: InferGetStaticPropsType) => ( 25 | 26 | 27 | {products && } 28 | 29 | ); 30 | 31 | export default Index; 32 | 33 | export const getStaticProps: GetStaticProps = async () => { 34 | const { data, loading, networkStatus } = await client.query({ 35 | query: FETCH_ALL_PRODUCTS_QUERY, 36 | }); 37 | 38 | return { 39 | props: { 40 | products: data.products.nodes, 41 | loading, 42 | networkStatus, 43 | }, 44 | revalidate: 60, 45 | }; 46 | }; 47 | -------------------------------------------------------------------------------- /src/pages/kasse.tsx: -------------------------------------------------------------------------------- 1 | // Components 2 | import Layout from '@/components/Layout/Layout.component'; 3 | import CheckoutForm from '@/components/Checkout/CheckoutForm.component'; 4 | 5 | // Types 6 | import type { NextPage } from 'next'; 7 | 8 | const Kasse: NextPage = () => ( 9 | 10 | 11 | 12 | ); 13 | 14 | export default Kasse; 15 | -------------------------------------------------------------------------------- /src/pages/kategori/[slug].tsx: -------------------------------------------------------------------------------- 1 | import { withRouter } from 'next/router'; 2 | 3 | // Components 4 | import Layout from '@/components/Layout/Layout.component'; 5 | import DisplayProducts from '@/components/Product/DisplayProducts.component'; 6 | 7 | import client from '@/utils/apollo/ApolloClient'; 8 | 9 | import { GET_PRODUCTS_FROM_CATEGORY } from '@/utils/gql/GQL_QUERIES'; 10 | import { GetServerSideProps, InferGetServerSidePropsType } from 'next'; 11 | 12 | /** 13 | * Display a single product with dynamic pretty urls 14 | */ 15 | const Produkt = ({ 16 | categoryName, 17 | products, 18 | }: InferGetServerSidePropsType) => { 19 | return ( 20 | 21 | {products ? ( 22 | 23 | ) : ( 24 |
Laster produkt ...
25 | )} 26 |
27 | ); 28 | }; 29 | 30 | export default withRouter(Produkt); 31 | 32 | export const getServerSideProps: GetServerSideProps = async ({ 33 | query: { id }, 34 | }) => { 35 | const res = await client.query({ 36 | query: GET_PRODUCTS_FROM_CATEGORY, 37 | variables: { id }, 38 | }); 39 | 40 | return { 41 | props: { 42 | categoryName: res.data.productCategory.name, 43 | products: res.data.productCategory.products.nodes, 44 | }, 45 | }; 46 | }; 47 | -------------------------------------------------------------------------------- /src/pages/kategorier.tsx: -------------------------------------------------------------------------------- 1 | import { NextPage, InferGetStaticPropsType, GetStaticProps } from 'next'; 2 | 3 | import Categories from '@/components/Category/Categories.component'; 4 | import Layout from '@/components/Layout/Layout.component'; 5 | 6 | import client from '@/utils/apollo/ApolloClient'; 7 | 8 | import { FETCH_ALL_CATEGORIES_QUERY } from '@/utils/gql/GQL_QUERIES'; 9 | 10 | /** 11 | * Category page displays all of the categories 12 | */ 13 | const Kategorier: NextPage = ({ 14 | categories, 15 | }: InferGetStaticPropsType) => ( 16 | 17 | {categories && } 18 | 19 | ); 20 | 21 | export default Kategorier; 22 | 23 | export const getStaticProps: GetStaticProps = async () => { 24 | const result = await client.query({ 25 | query: FETCH_ALL_CATEGORIES_QUERY, 26 | }); 27 | 28 | return { 29 | props: { 30 | categories: result.data.productCategories.nodes, 31 | }, 32 | revalidate: 10, 33 | }; 34 | }; 35 | -------------------------------------------------------------------------------- /src/pages/produkt/[slug].tsx: -------------------------------------------------------------------------------- 1 | // Imports 2 | import { withRouter } from 'next/router'; 3 | 4 | // Components 5 | import SingleProduct from '@/components/Product/SingleProduct.component'; 6 | import Layout from '@/components/Layout/Layout.component'; 7 | 8 | // Utilities 9 | import client from '@/utils/apollo/ApolloClient'; 10 | 11 | // Types 12 | import type { 13 | NextPage, 14 | GetServerSideProps, 15 | InferGetServerSidePropsType, 16 | } from 'next'; 17 | 18 | // GraphQL 19 | import { GET_SINGLE_PRODUCT } from '@/utils/gql/GQL_QUERIES'; 20 | 21 | /** 22 | * Display a single product with dynamic pretty urls 23 | * @function Produkt 24 | * @param {InferGetServerSidePropsType} products 25 | * @returns {JSX.Element} - Rendered component 26 | */ 27 | const Produkt: NextPage = ({ 28 | product, 29 | networkStatus, 30 | }: InferGetServerSidePropsType) => { 31 | const hasError = networkStatus === '8'; 32 | return ( 33 | 34 | {product ? ( 35 | 36 | ) : ( 37 |
Laster produkt ...
38 | )} 39 | {hasError && ( 40 |
41 | Feil under lasting av produkt ... 42 |
43 | )} 44 |
45 | ); 46 | }; 47 | 48 | export default withRouter(Produkt); 49 | 50 | export const getServerSideProps: GetServerSideProps = async ({ 51 | params, 52 | query, 53 | res, 54 | }) => { 55 | // Handle legacy URLs with ID parameter by removing it 56 | if (query.id) { 57 | res.setHeader('Location', `/produkt/${params?.slug}`); 58 | res.statusCode = 301; 59 | res.end(); 60 | return { props: {} }; 61 | } 62 | 63 | const { data, loading, networkStatus } = await client.query({ 64 | query: GET_SINGLE_PRODUCT, 65 | variables: { slug: params?.slug }, 66 | }); 67 | 68 | return { 69 | props: { product: data.product, loading, networkStatus }, 70 | }; 71 | }; 72 | -------------------------------------------------------------------------------- /src/pages/produkter.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import Layout from '@/components/Layout/Layout.component'; 3 | import ProductList from '@/components/Product/ProductList.component'; 4 | import client from '@/utils/apollo/ApolloClient'; 5 | import { FETCH_ALL_PRODUCTS_QUERY } from '@/utils/gql/GQL_QUERIES'; 6 | import type { NextPage, GetStaticProps, InferGetStaticPropsType } from 'next'; 7 | 8 | const Produkter: NextPage = ({ 9 | products, 10 | loading, 11 | }: InferGetStaticPropsType) => { 12 | if (loading) 13 | return ( 14 | 15 |
16 |
17 |
18 |
19 | ); 20 | 21 | if (!products) 22 | return ( 23 | 24 |
25 |

Ingen produkter funnet

26 |
27 |
28 | ); 29 | 30 | return ( 31 | 32 | 33 | Produkter | WooCommerce Next.js 34 | 35 | 36 |
37 | 38 |
39 |
40 | ); 41 | }; 42 | 43 | export default Produkter; 44 | 45 | export const getStaticProps: GetStaticProps = async () => { 46 | const { data, loading, networkStatus } = await client.query({ 47 | query: FETCH_ALL_PRODUCTS_QUERY, 48 | }); 49 | 50 | return { 51 | props: { 52 | products: data.products.nodes, 53 | loading, 54 | networkStatus, 55 | }, 56 | revalidate: 60, 57 | }; 58 | }; 59 | -------------------------------------------------------------------------------- /src/stores/cartStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | import { persist } from 'zustand/middleware'; 3 | 4 | interface Image { 5 | sourceUrl?: string; 6 | srcSet?: string; 7 | title: string; 8 | } 9 | 10 | export interface Product { 11 | cartKey: string; 12 | name: string; 13 | qty: number; 14 | price: number; 15 | totalPrice: string; 16 | image: Image; 17 | productId: number; 18 | } 19 | 20 | interface CartState { 21 | cart: { 22 | products: Product[]; 23 | totalProductsCount: number; 24 | totalProductsPrice: number; 25 | } | null; 26 | isLoading: boolean; 27 | setCart: (cart: CartState['cart']) => void; 28 | updateCart: (newCart: NonNullable) => void; 29 | syncWithWooCommerce: (cart: NonNullable) => void; 30 | clearWooCommerceSession: () => void; 31 | } 32 | 33 | export const useCartStore = create()( 34 | persist( 35 | (set) => ({ 36 | cart: null, 37 | isLoading: false, 38 | setCart: (cart) => set({ cart }), 39 | updateCart: (newCart) => { 40 | set({ cart: newCart }); 41 | // Sync with WooCommerce 42 | localStorage.setItem('woocommerce-cart', JSON.stringify(newCart)); 43 | }, 44 | syncWithWooCommerce: (cart) => { 45 | set({ cart }); 46 | localStorage.setItem('woocommerce-cart', JSON.stringify(cart)); 47 | }, 48 | clearWooCommerceSession: () => { 49 | set({ cart: null }); 50 | localStorage.removeItem('woo-session'); 51 | localStorage.removeItem('woocommerce-cart'); 52 | }, 53 | }), 54 | { 55 | name: 'cart-store', 56 | partialize: (state) => ({ cart: state.cart }), 57 | }, 58 | ), 59 | ); 60 | -------------------------------------------------------------------------------- /src/styles/algolia.min.css: -------------------------------------------------------------------------------- 1 | .ais-Breadcrumb-list,.ais-CurrentRefinements-list,.ais-HierarchicalMenu-list,.ais-Hits-list,.ais-InfiniteHits-list,.ais-InfiniteResults-list,.ais-Menu-list,.ais-NumericMenu-list,.ais-Pagination-list,.ais-RatingMenu-list,.ais-RefinementList-list,.ais-Results-list,.ais-ToggleRefinement-list{margin:0;padding:0;list-style:none}.ais-ClearRefinements-button,.ais-CurrentRefinements-delete,.ais-CurrentRefinements-reset,.ais-GeoSearch-redo,.ais-GeoSearch-reset,.ais-HierarchicalMenu-showMore,.ais-InfiniteHits-loadMore,.ais-InfiniteHits-loadPrevious,.ais-InfiniteResults-loadMore,.ais-Menu-showMore,.ais-RangeInput-submit,.ais-RefinementList-showMore,.ais-SearchBox-reset,.ais-SearchBox-submit,.ais-VoiceSearch-button{padding:0;overflow:visible;font:inherit;line-height:normal;color:inherit;background:none;border:0;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.ais-ClearRefinements-button::-moz-focus-inner,.ais-CurrentRefinements-delete::-moz-focus-inner,.ais-CurrentRefinements-reset::-moz-focus-inner,.ais-GeoSearch-redo::-moz-focus-inner,.ais-GeoSearch-reset::-moz-focus-inner,.ais-HierarchicalMenu-showMore::-moz-focus-inner,.ais-InfiniteHits-loadMore::-moz-focus-inner,.ais-InfiniteHits-loadPrevious::-moz-focus-inner,.ais-InfiniteResults-loadMore::-moz-focus-inner,.ais-Menu-showMore::-moz-focus-inner,.ais-RangeInput-submit::-moz-focus-inner,.ais-RefinementList-showMore::-moz-focus-inner,.ais-SearchBox-reset::-moz-focus-inner,.ais-SearchBox-submit::-moz-focus-inner,.ais-VoiceSearch-button::-moz-focus-inner{padding:0;border:0}.ais-ClearRefinements-button[disabled],.ais-CurrentRefinements-delete[disabled],.ais-CurrentRefinements-reset[disabled],.ais-GeoSearch-redo[disabled],.ais-GeoSearch-reset[disabled],.ais-HierarchicalMenu-showMore[disabled],.ais-InfiniteHits-loadMore[disabled],.ais-InfiniteHits-loadPrevious[disabled],.ais-InfiniteResults-loadMore[disabled],.ais-Menu-showMore[disabled],.ais-RangeInput-submit[disabled],.ais-RefinementList-showMore[disabled],.ais-SearchBox-reset[disabled],.ais-SearchBox-submit[disabled],.ais-VoiceSearch-button[disabled]{cursor:default}.ais-Breadcrumb-item,.ais-Breadcrumb-list,.ais-Pagination-list,.ais-PoweredBy,.ais-RangeInput-form,.ais-RatingMenu-link{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.ais-GeoSearch,.ais-GeoSearch-map{height:100%}.ais-HierarchicalMenu-list .ais-HierarchicalMenu-list{margin-left:1em}.ais-PoweredBy-logo{display:block;height:1.2em;width:auto}.ais-RatingMenu-starIcon{display:block;width:20px;height:20px}.ais-SearchBox-input::-ms-clear,.ais-SearchBox-input::-ms-reveal{display:none;width:0;height:0}.ais-SearchBox-input::-webkit-search-cancel-button,.ais-SearchBox-input::-webkit-search-decoration,.ais-SearchBox-input::-webkit-search-results-button,.ais-SearchBox-input::-webkit-search-results-decoration{display:none}.ais-RangeSlider .rheostat{overflow:visible;margin-top:40px;margin-bottom:40px}.ais-RangeSlider .rheostat-background{height:6px;top:0;width:100%}.ais-RangeSlider .rheostat-handle{margin-left:-12px;top:-7px}.ais-RangeSlider .rheostat-background{position:relative;background-color:#fff;border:1px solid #aaa}.ais-RangeSlider .rheostat-progress{position:absolute;top:1px;height:4px;background-color:#333}.rheostat-handle{position:relative;z-index:1;width:20px;height:20px;background-color:#fff;border:1px solid #333;border-radius:50%;cursor:-webkit-grab;cursor:grab}.rheostat-marker{margin-left:-1px;position:absolute;width:1px;height:5px;background-color:#aaa}.rheostat-marker--large{height:9px}.rheostat-value{padding-top:15px}.rheostat-tooltip,.rheostat-value{margin-left:50%;position:absolute;text-align:center;-webkit-transform:translateX(-50%);transform:translateX(-50%)}.rheostat-tooltip{top:-22px} -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | #cart-div { 6 | width: 300px; 7 | } 8 | 9 | #closeXsearch { 10 | padding-left: 5px; 11 | } 12 | 13 | #close-cart-p { 14 | margin-top: 2px; 15 | } 16 | 17 | #mobile-search-close-p { 18 | margin-top: -20px; 19 | } 20 | 21 | /* Fix Algolia mobile searchbox design issues */ 22 | 23 | #mobilesearchdiv { 24 | position: absolute; 25 | height: 200px; 26 | } 27 | 28 | .ais-SearchBox-submit { 29 | width: 48px; 30 | } 31 | 32 | .ais-SearchBox-submitIcon { 33 | display: none; 34 | } 35 | 36 | .ais-SearchBox-reset { 37 | margin-left: 10px; 38 | } 39 | 40 | /* Fix Chrome padding issue */ 41 | 42 | .ais-SearchBox-input[type='search']::-webkit-search-cancel-button { 43 | display: none; 44 | } 45 | -------------------------------------------------------------------------------- /src/tests/Categories/Categories.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test.describe('Categories Navigation', () => { 4 | test.beforeEach(async ({ page }) => { 5 | await page.goto('http://localhost:3000/'); 6 | }); 7 | 8 | test('should navigate through category pages', async ({ page }) => { 9 | // Navigate to categories page 10 | await page.getByRole('link', { name: 'Kategorier' }).click(); 11 | await expect(page).toHaveURL('http://localhost:3000/kategorier'); 12 | 13 | // Click a category and verify navigation 14 | await page.getByRole('link', { name: 'Clothing' }).click(); 15 | await expect(page).toHaveURL(/^http:\/\/localhost:3000\/kategori\/clothing/); 16 | 17 | // Go back to categories 18 | await page.getByRole('link', { name: 'Kategorier' }).click(); 19 | await expect(page).toHaveURL('http://localhost:3000/kategorier'); 20 | 21 | // Try another category 22 | await page.getByRole('link', { name: 'Tshirts' }).click(); 23 | await expect(page).toHaveURL(/^http:\/\/localhost:3000\/kategori\/tshirts/); 24 | }); 25 | 26 | test('should navigate between categories and home', async ({ page }) => { 27 | // Go to categories 28 | await page.getByRole('link', { name: 'Kategorier' }).click(); 29 | await expect(page).toHaveURL('http://localhost:3000/kategorier'); 30 | 31 | // Go back home 32 | await page.getByRole('link', { name: 'NETTBUTIKK' }).click(); 33 | await expect(page).toHaveURL('http://localhost:3000/'); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/tests/Index/Index.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test'; 2 | 3 | test.describe('Forside', () => { 4 | test.beforeEach(async ({ page }) => { 5 | await page.goto('http://localhost:3000'); 6 | }); 7 | 8 | test('Har h1 innhold på forsiden', async ({ page }) => { 9 | const h1 = await page.locator('h1'); 10 | const count = await h1.count(); 11 | await expect(count).toBeGreaterThan(0); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/types/product.ts: -------------------------------------------------------------------------------- 1 | export interface Image { 2 | __typename: string; 3 | sourceUrl?: string; 4 | } 5 | 6 | export interface Node { 7 | __typename: string; 8 | price: string; 9 | regularPrice: string; 10 | salePrice?: string; 11 | } 12 | 13 | export interface ProductCategory { 14 | name: string; 15 | slug: string; 16 | } 17 | 18 | export interface ColorNode { 19 | name: string; 20 | slug: string; 21 | } 22 | 23 | export interface SizeNode { 24 | name: string; 25 | } 26 | 27 | export interface AttributeNode { 28 | name: string; 29 | value: string; 30 | } 31 | 32 | export interface Product { 33 | __typename: string; 34 | databaseId: number; 35 | name: string; 36 | onSale: boolean; 37 | slug: string; 38 | image: Image; 39 | price: string; 40 | regularPrice: string; 41 | salePrice?: string; 42 | productCategories?: { 43 | nodes: ProductCategory[]; 44 | }; 45 | allPaColors?: { 46 | nodes: ColorNode[]; 47 | }; 48 | allPaSizes?: { 49 | nodes: SizeNode[]; 50 | }; 51 | variations: { 52 | nodes: Array<{ 53 | price: string; 54 | regularPrice: string; 55 | salePrice?: string; 56 | attributes?: { 57 | nodes: AttributeNode[]; 58 | }; 59 | }>; 60 | }; 61 | } 62 | 63 | export interface ProductType { 64 | id: string; 65 | name: string; 66 | checked: boolean; 67 | } 68 | -------------------------------------------------------------------------------- /src/utils/apollo/ApolloClient.js: -------------------------------------------------------------------------------- 1 | /*eslint complexity: ["error", 6]*/ 2 | 3 | import { 4 | ApolloClient, 5 | InMemoryCache, 6 | createHttpLink, 7 | ApolloLink, 8 | } from '@apollo/client'; 9 | 10 | const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds 11 | 12 | /** 13 | * Middleware operation 14 | * If we have a session token in localStorage, add it to the GraphQL request as a Session header. 15 | */ 16 | export const middleware = new ApolloLink((operation, forward) => { 17 | /** 18 | * If session data exist in local storage, set value as session header. 19 | * Here we also delete the session if it is older than 7 days 20 | */ 21 | const sessionData = process.browser 22 | ? JSON.parse(localStorage.getItem('woo-session')) 23 | : null; 24 | 25 | if (sessionData && sessionData.token && sessionData.createdTime) { 26 | const { token, createdTime } = sessionData; 27 | 28 | // Check if the token is older than 7 days 29 | if (Date.now() - createdTime > SEVEN_DAYS) { 30 | // If it is, delete it 31 | localStorage.removeItem('woo-session'); 32 | localStorage.setItem('woocommerce-cart', JSON.stringify({})); 33 | } else { 34 | // If it's not, use the token 35 | operation.setContext(() => ({ 36 | headers: { 37 | 'woocommerce-session': `Session ${token}`, 38 | }, 39 | })); 40 | } 41 | } 42 | 43 | return forward(operation); 44 | }); 45 | 46 | /** 47 | * Afterware operation. 48 | * 49 | * This catches the incoming session token and stores it in localStorage, for future GraphQL requests. 50 | */ 51 | export const afterware = new ApolloLink((operation, forward) => 52 | forward(operation).map((response) => { 53 | /** 54 | * Check for session header and update session in local storage accordingly. 55 | */ 56 | const context = operation.getContext(); 57 | const { 58 | response: { headers }, 59 | } = context; 60 | 61 | const session = headers.get('woocommerce-session'); 62 | 63 | if (session && process.browser) { 64 | if ('false' === session) { 65 | // Remove session data if session destroyed. 66 | localStorage.removeItem('woo-session'); 67 | // Update session new data if changed. 68 | } else if (!localStorage.getItem('woo-session')) { 69 | localStorage.setItem( 70 | 'woo-session', 71 | JSON.stringify({ token: session, createdTime: Date.now() }), 72 | ); 73 | } 74 | } 75 | 76 | return response; 77 | }), 78 | ); 79 | 80 | const clientSide = typeof window === 'undefined'; 81 | 82 | // Apollo GraphQL client. 83 | const client = new ApolloClient({ 84 | ssrMode: clientSide, 85 | link: middleware.concat( 86 | afterware.concat( 87 | createHttpLink({ 88 | uri: process.env.NEXT_PUBLIC_GRAPHQL_URL, 89 | fetch, 90 | }), 91 | ), 92 | ), 93 | cache: new InMemoryCache(), 94 | }); 95 | 96 | export default client; 97 | -------------------------------------------------------------------------------- /src/utils/constants/INPUT_FIELDS.ts: -------------------------------------------------------------------------------- 1 | export const INPUT_FIELDS = [ 2 | { 3 | id: 0, 4 | label: 'Fornavn', 5 | name: 'firstName', 6 | customValidation: { required: true, minlength: 4 }, 7 | }, 8 | { 9 | id: 1, 10 | label: 'Etternavn', 11 | name: 'lastName', 12 | customValidation: { required: true, minlength: 4 }, 13 | }, 14 | { 15 | id: 2, 16 | label: 'Adresse', 17 | name: 'address1', 18 | customValidation: { required: true, minlength: 4 }, 19 | }, 20 | { 21 | id: 3, 22 | label: 'Postnummer', 23 | name: 'postcode', 24 | customValidation: { required: true, minlength: 4, pattern: '[+0-9]{4,6}' }, 25 | }, 26 | { 27 | id: 4, 28 | label: 'Sted', 29 | name: 'city', 30 | customValidation: { required: true, minlength: 2 }, 31 | }, 32 | { 33 | id: 5, 34 | label: 'Epost', 35 | name: 'email', 36 | customValidation: { required: true, type: 'email' }, 37 | }, 38 | { 39 | id: 6, 40 | label: 'Telefon', 41 | name: 'phone', 42 | customValidation: { required: true, minlength: 8, pattern: '[+0-9]{8,12}' }, 43 | }, 44 | ]; 45 | -------------------------------------------------------------------------------- /src/utils/constants/LINKS.ts: -------------------------------------------------------------------------------- 1 | interface ILinks { 2 | id: number; 3 | title: string; 4 | href: string; 5 | } 6 | 7 | const LINKS: ILinks[] = [ 8 | { 9 | id: 0, 10 | title: 'Hjem', 11 | href: '/', 12 | }, 13 | { 14 | id: 1, 15 | title: 'Produkter', 16 | href: '/produkter', 17 | }, 18 | { 19 | id: 2, 20 | title: 'Kategorier', 21 | href: '/kategorier', 22 | }, 23 | ]; 24 | 25 | export default LINKS; 26 | -------------------------------------------------------------------------------- /src/utils/functions/functions.tsx: -------------------------------------------------------------------------------- 1 | /*eslint complexity: ["error", 20]*/ 2 | 3 | import { v4 as uuidv4 } from 'uuid'; 4 | 5 | import type { Product } from '@/stores/cartStore'; 6 | 7 | interface RootObject { 8 | products: Product[]; 9 | totalProductsCount: number; 10 | totalProductsPrice: number; 11 | } 12 | 13 | import { ChangeEvent } from 'react'; 14 | import { IVariationNodes } from '@/components/Product/AddToCart.component'; 15 | 16 | /* Interface for products*/ 17 | 18 | export interface IImage { 19 | __typename: string; 20 | id: string; 21 | sourceUrl?: string; 22 | srcSet?: string; 23 | altText: string; 24 | title: string; 25 | } 26 | 27 | export interface IGalleryImages { 28 | __typename: string; 29 | nodes: IImage[]; 30 | } 31 | 32 | interface IProductNode { 33 | __typename: string; 34 | id: string; 35 | databaseId: number; 36 | name: string; 37 | description: string; 38 | type: string; 39 | onSale: boolean; 40 | slug: string; 41 | averageRating: number; 42 | reviewCount: number; 43 | image: IImage; 44 | galleryImages: IGalleryImages; 45 | productId: number; 46 | } 47 | 48 | interface IProduct { 49 | __typename: string; 50 | node: IProductNode; 51 | } 52 | 53 | export interface IProductRootObject { 54 | __typename: string; 55 | key: string; 56 | product: IProduct; 57 | variation?: IVariationNodes; 58 | quantity: number; 59 | total: string; 60 | subtotal: string; 61 | subtotalTax: string; 62 | } 63 | 64 | type TUpdatedItems = { key: string; quantity: number }[]; 65 | 66 | export interface IUpdateCartItem { 67 | key: string; 68 | quantity: number; 69 | } 70 | 71 | export interface IUpdateCartInput { 72 | clientMutationId: string; 73 | items: IUpdateCartItem[]; 74 | } 75 | 76 | export interface IUpdateCartVariables { 77 | input: IUpdateCartInput; 78 | } 79 | 80 | export interface IUpdateCartRootObject { 81 | variables: IUpdateCartVariables; 82 | } 83 | 84 | /* Interface for props */ 85 | 86 | interface IFormattedCartProps { 87 | cart: { contents: { nodes: IProductRootObject[] }; total: number }; 88 | } 89 | 90 | export interface ICheckoutDataProps { 91 | firstName: string; 92 | lastName: string; 93 | address1: string; 94 | address2: string; 95 | city: string; 96 | country: string; 97 | state: string; 98 | postcode: string; 99 | email: string; 100 | phone: string; 101 | company: string; 102 | paymentMethod: string; 103 | } 104 | 105 | /** 106 | * Add empty character after currency symbol 107 | * @param {string} price The price string that we input 108 | * @param {string} symbol Currency symbol to add empty character/padding after 109 | */ 110 | 111 | export const paddedPrice = (price: string, symbol: string) => 112 | price.split(symbol).join(`${symbol} `); 113 | 114 | /** 115 | * Shorten inputted string (usually product description) to a maximum of length 116 | * @param {string} input The string that we input 117 | * @param {number} length The length that we want to shorten the text to 118 | */ 119 | export const trimmedStringToLength = (input: string, length: number) => { 120 | if (input.length > length) { 121 | const subStr = input.substring(0, length); 122 | return `${subStr}...`; 123 | } 124 | return input; 125 | }; 126 | 127 | /** 128 | * Filter variant price. Changes "kr198.00 - kr299.00" to kr299.00 or kr198 depending on the side variable 129 | * @param {String} side Which side of the string to return (which side of the "-" symbol) 130 | * @param {String} price The inputted price that we need to convert 131 | */ 132 | export const filteredVariantPrice = (price: string, side: string) => { 133 | if ('right' === side) { 134 | return price.substring(price.length, price.indexOf('-')).replace('-', ''); 135 | } 136 | 137 | return price.substring(0, price.indexOf('-')).replace('-', ''); 138 | }; 139 | 140 | /** 141 | * Returns cart data in the required format. 142 | * @param {String} data Cart data 143 | */ 144 | 145 | export const getFormattedCart = (data: IFormattedCartProps) => { 146 | const formattedCart: RootObject = { 147 | products: [], 148 | totalProductsCount: 0, 149 | totalProductsPrice: 0, 150 | }; 151 | 152 | if (!data) { 153 | return; 154 | } 155 | const givenProducts = data.cart.contents.nodes; 156 | 157 | // Create an empty object. 158 | formattedCart.products = []; 159 | 160 | const product: Product = { 161 | productId: 0, 162 | cartKey: '', 163 | name: '', 164 | qty: 0, 165 | price: 0, 166 | totalPrice: '0', 167 | image: { sourceUrl: '', srcSet: '', title: '' }, 168 | }; 169 | 170 | let totalProductsCount = 0; 171 | let i = 0; 172 | 173 | if (!givenProducts.length) { 174 | return; 175 | } 176 | 177 | givenProducts.forEach(() => { 178 | const givenProduct = givenProducts[Number(i)].product.node; 179 | 180 | // Convert price to a float value 181 | const convertedCurrency = givenProducts[Number(i)].total.replace( 182 | /[^0-9.-]+/g, 183 | '', 184 | ); 185 | 186 | product.productId = givenProduct.productId; 187 | product.cartKey = givenProducts[Number(i)].key; 188 | product.name = givenProduct.name; 189 | product.qty = givenProducts[Number(i)].quantity; 190 | product.price = Number(convertedCurrency) / product.qty; 191 | product.totalPrice = givenProducts[Number(i)].total; 192 | 193 | // Ensure we can add products without images to the cart 194 | 195 | product.image = givenProduct.image.sourceUrl 196 | ? { 197 | sourceUrl: givenProduct.image.sourceUrl, 198 | srcSet: givenProduct.image.srcSet, 199 | title: givenProduct.image.title, 200 | } 201 | : { 202 | sourceUrl: process.env.NEXT_PUBLIC_PLACEHOLDER_SMALL_IMAGE_URL, 203 | srcSet: process.env.NEXT_PUBLIC_PLACEHOLDER_SMALL_IMAGE_URL, 204 | title: givenProduct.name, 205 | }; 206 | 207 | totalProductsCount += givenProducts[Number(i)].quantity; 208 | 209 | // Push each item into the products array. 210 | formattedCart.products.push(product); 211 | i++; 212 | }); 213 | formattedCart.totalProductsCount = totalProductsCount; 214 | formattedCart.totalProductsPrice = data.cart.total; 215 | 216 | return formattedCart; 217 | }; 218 | 219 | export const createCheckoutData = (order: ICheckoutDataProps) => ({ 220 | clientMutationId: uuidv4(), 221 | billing: { 222 | firstName: order.firstName, 223 | lastName: order.lastName, 224 | address1: order.address1, 225 | address2: order.address2, 226 | city: order.city, 227 | country: order.country, 228 | state: order.state, 229 | postcode: order.postcode, 230 | email: order.email, 231 | phone: order.phone, 232 | company: order.company, 233 | }, 234 | shipping: { 235 | firstName: order.firstName, 236 | lastName: order.lastName, 237 | address1: order.address1, 238 | address2: order.address2, 239 | city: order.city, 240 | country: order.country, 241 | state: order.state, 242 | postcode: order.postcode, 243 | email: order.email, 244 | phone: order.phone, 245 | company: order.company, 246 | }, 247 | shipToDifferentAddress: false, 248 | paymentMethod: order.paymentMethod, 249 | isPaid: false, 250 | transactionId: 'fhggdfjgfi', 251 | }); 252 | 253 | /** 254 | * Get the updated items in the below format required for mutation input. 255 | * 256 | * Creates an array in above format with the newQty (updated Qty ). 257 | * 258 | */ 259 | export const getUpdatedItems = ( 260 | products: IProductRootObject[], 261 | newQty: number, 262 | cartKey: string, 263 | ) => { 264 | // Create an empty array. 265 | 266 | const updatedItems: TUpdatedItems = []; 267 | 268 | // Loop through the product array. 269 | products.forEach((cartItem) => { 270 | // If you find the cart key of the product user is trying to update, push the key and new qty. 271 | if (cartItem.key === cartKey) { 272 | updatedItems.push({ 273 | key: cartItem.key, 274 | quantity: newQty, 275 | }); 276 | 277 | // Otherwise just push the existing qty without updating. 278 | } else { 279 | updatedItems.push({ 280 | key: cartItem.key, 281 | quantity: cartItem.quantity, 282 | }); 283 | } 284 | }); 285 | 286 | // Return the updatedItems array with new Qtys. 287 | return updatedItems; 288 | }; 289 | 290 | /* 291 | * When user changes the quantity, update the cart in localStorage 292 | * Also update the cart in the global Context 293 | */ 294 | export const handleQuantityChange = ( 295 | event: ChangeEvent, 296 | cartKey: string, 297 | cart: IProductRootObject[], 298 | updateCart: (variables: IUpdateCartRootObject) => void, 299 | updateCartProcessing: boolean, 300 | ) => { 301 | if (process.browser) { 302 | event.stopPropagation(); 303 | 304 | // Return if the previous update cart mutation request is still processing 305 | if (updateCartProcessing || !cart) { 306 | return; 307 | } 308 | 309 | // If the user tries to delete the count of product, set that to 1 by default ( This will not allow him to reduce it less than zero ) 310 | const newQty = event.target.value ? parseInt(event.target.value, 10) : 1; 311 | 312 | if (cart.length) { 313 | const updatedItems = getUpdatedItems(cart, newQty, cartKey); 314 | 315 | updateCart({ 316 | variables: { 317 | input: { 318 | clientMutationId: uuidv4(), 319 | items: updatedItems, 320 | }, 321 | }, 322 | }); 323 | } 324 | } 325 | }; 326 | -------------------------------------------------------------------------------- /src/utils/functions/productUtils.ts: -------------------------------------------------------------------------------- 1 | import { Product, ProductCategory, ProductType } from '@/types/product'; 2 | 3 | export const getUniqueProductTypes = (products: Product[]): ProductType[] => { 4 | // Use Map to ensure unique categories by slug 5 | const categoryMap = new Map(); 6 | 7 | products?.forEach((product) => { 8 | product.productCategories?.nodes.forEach((cat: ProductCategory) => { 9 | if (!categoryMap.has(cat.slug)) { 10 | categoryMap.set(cat.slug, { 11 | id: cat.slug, 12 | name: cat.name, 13 | checked: false, 14 | }); 15 | } 16 | }); 17 | }); 18 | 19 | // Convert Map values to array and sort by name 20 | return Array.from(categoryMap.values()).sort((a, b) => 21 | a.name.localeCompare(b.name), 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /src/utils/gql/GQL_MUTATIONS.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | export const CREATE_USER = gql` 4 | mutation CreateUser( 5 | $username: String! 6 | $email: String! 7 | $password: String! 8 | $firstName: String 9 | $lastName: String 10 | ) { 11 | registerCustomer( 12 | input: { 13 | username: $username 14 | email: $email 15 | password: $password 16 | firstName: $firstName 17 | lastName: $lastName 18 | } 19 | ) { 20 | customer { 21 | id 22 | email 23 | firstName 24 | lastName 25 | username 26 | } 27 | } 28 | } 29 | `; 30 | 31 | export const ADD_TO_CART = gql` 32 | mutation ($input: AddToCartInput!) { 33 | addToCart(input: $input) { 34 | cartItem { 35 | key 36 | product { 37 | node { 38 | id 39 | databaseId 40 | name 41 | description 42 | type 43 | onSale 44 | slug 45 | averageRating 46 | reviewCount 47 | image { 48 | id 49 | sourceUrl 50 | altText 51 | } 52 | galleryImages { 53 | nodes { 54 | id 55 | sourceUrl 56 | altText 57 | } 58 | } 59 | } 60 | } 61 | variation { 62 | node { 63 | id 64 | databaseId 65 | name 66 | description 67 | type 68 | onSale 69 | price 70 | regularPrice 71 | salePrice 72 | image { 73 | id 74 | sourceUrl 75 | altText 76 | } 77 | attributes { 78 | nodes { 79 | id 80 | attributeId 81 | name 82 | value 83 | } 84 | } 85 | } 86 | } 87 | quantity 88 | total 89 | subtotal 90 | subtotalTax 91 | } 92 | } 93 | } 94 | `; 95 | 96 | export const CHECKOUT_MUTATION = gql` 97 | mutation CHECKOUT_MUTATION($input: CheckoutInput!) { 98 | checkout(input: $input) { 99 | result 100 | redirect 101 | } 102 | } 103 | `; 104 | export const UPDATE_CART = gql` 105 | mutation ($input: UpdateItemQuantitiesInput!) { 106 | updateItemQuantities(input: $input) { 107 | items { 108 | key 109 | product { 110 | node { 111 | id 112 | databaseId 113 | name 114 | description 115 | type 116 | onSale 117 | slug 118 | averageRating 119 | reviewCount 120 | image { 121 | id 122 | sourceUrl 123 | altText 124 | } 125 | galleryImages { 126 | nodes { 127 | id 128 | sourceUrl 129 | altText 130 | } 131 | } 132 | } 133 | } 134 | 135 | variation { 136 | node { 137 | id 138 | databaseId 139 | name 140 | description 141 | type 142 | onSale 143 | price 144 | regularPrice 145 | salePrice 146 | image { 147 | id 148 | sourceUrl 149 | altText 150 | } 151 | attributes { 152 | nodes { 153 | id 154 | attributeId 155 | name 156 | value 157 | } 158 | } 159 | } 160 | } 161 | quantity 162 | total 163 | subtotal 164 | subtotalTax 165 | } 166 | removed { 167 | key 168 | product { 169 | node { 170 | id 171 | databaseId 172 | } 173 | } 174 | variation { 175 | node { 176 | id 177 | databaseId 178 | } 179 | } 180 | } 181 | updated { 182 | key 183 | product { 184 | node { 185 | id 186 | databaseId 187 | } 188 | } 189 | 190 | variation { 191 | node { 192 | id 193 | databaseId 194 | } 195 | } 196 | } 197 | } 198 | } 199 | `; 200 | -------------------------------------------------------------------------------- /src/utils/gql/GQL_QUERIES.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | export const GET_SINGLE_PRODUCT = gql` 4 | query Product($slug: ID!) { 5 | product(id: $slug, idType: SLUG) { 6 | id 7 | databaseId 8 | averageRating 9 | slug 10 | description 11 | onSale 12 | image { 13 | id 14 | uri 15 | title 16 | srcSet 17 | sourceUrl 18 | } 19 | name 20 | ... on SimpleProduct { 21 | salePrice 22 | regularPrice 23 | price 24 | id 25 | stockQuantity 26 | } 27 | ... on VariableProduct { 28 | salePrice 29 | regularPrice 30 | price 31 | id 32 | allPaColors { 33 | nodes { 34 | name 35 | } 36 | } 37 | allPaSizes { 38 | nodes { 39 | name 40 | } 41 | } 42 | variations { 43 | nodes { 44 | id 45 | databaseId 46 | name 47 | stockStatus 48 | stockQuantity 49 | purchasable 50 | onSale 51 | salePrice 52 | regularPrice 53 | } 54 | } 55 | } 56 | ... on ExternalProduct { 57 | price 58 | id 59 | externalUrl 60 | } 61 | ... on GroupProduct { 62 | products { 63 | nodes { 64 | ... on SimpleProduct { 65 | id 66 | price 67 | } 68 | } 69 | } 70 | id 71 | } 72 | } 73 | } 74 | `; 75 | 76 | /** 77 | * Fetch first 4 products from a specific category 78 | */ 79 | 80 | export const FETCH_FIRST_PRODUCTS_FROM_HOODIES_QUERY = ` 81 | query MyQuery { 82 | products(first: 4, where: {category: "Hoodies"}) { 83 | nodes { 84 | productId 85 | name 86 | onSale 87 | slug 88 | image { 89 | sourceUrl 90 | } 91 | ... on SimpleProduct { 92 | price 93 | regularPrice 94 | salePrice 95 | } 96 | ... on VariableProduct { 97 | price 98 | regularPrice 99 | salePrice 100 | } 101 | } 102 | } 103 | } 104 | `; 105 | 106 | /** 107 | * Fetch first 200 Woocommerce products from GraphQL 108 | */ 109 | export const FETCH_ALL_PRODUCTS_QUERY = gql` 110 | query MyQuery { 111 | products(first: 50) { 112 | nodes { 113 | databaseId 114 | name 115 | onSale 116 | slug 117 | image { 118 | sourceUrl 119 | } 120 | productCategories { 121 | nodes { 122 | name 123 | slug 124 | } 125 | } 126 | ... on SimpleProduct { 127 | databaseId 128 | price 129 | regularPrice 130 | salePrice 131 | } 132 | ... on VariableProduct { 133 | databaseId 134 | price 135 | regularPrice 136 | salePrice 137 | allPaColors { 138 | nodes { 139 | name 140 | slug 141 | } 142 | } 143 | allPaSizes { 144 | nodes { 145 | name 146 | } 147 | } 148 | variations { 149 | nodes { 150 | price 151 | regularPrice 152 | salePrice 153 | attributes { 154 | nodes { 155 | name 156 | value 157 | } 158 | } 159 | } 160 | } 161 | } 162 | } 163 | } 164 | } 165 | `; 166 | 167 | /** 168 | * Fetch first 20 categories from GraphQL 169 | */ 170 | export const FETCH_ALL_CATEGORIES_QUERY = gql` 171 | query Categories { 172 | productCategories(first: 20) { 173 | nodes { 174 | id 175 | name 176 | slug 177 | } 178 | } 179 | } 180 | `; 181 | 182 | export const GET_PRODUCTS_FROM_CATEGORY = gql` 183 | query ProductsFromCategory($id: ID!) { 184 | productCategory(id: $id) { 185 | id 186 | name 187 | products(first: 50) { 188 | nodes { 189 | id 190 | databaseId 191 | onSale 192 | averageRating 193 | slug 194 | description 195 | image { 196 | id 197 | uri 198 | title 199 | srcSet 200 | sourceUrl 201 | } 202 | name 203 | ... on SimpleProduct { 204 | salePrice 205 | regularPrice 206 | onSale 207 | price 208 | id 209 | } 210 | ... on VariableProduct { 211 | salePrice 212 | regularPrice 213 | onSale 214 | price 215 | id 216 | } 217 | ... on ExternalProduct { 218 | price 219 | id 220 | externalUrl 221 | } 222 | ... on GroupProduct { 223 | products { 224 | nodes { 225 | ... on SimpleProduct { 226 | id 227 | price 228 | } 229 | } 230 | } 231 | id 232 | } 233 | } 234 | } 235 | } 236 | } 237 | `; 238 | 239 | export const GET_CART = gql` 240 | query GET_CART { 241 | cart { 242 | contents { 243 | nodes { 244 | key 245 | product { 246 | node { 247 | id 248 | databaseId 249 | name 250 | description 251 | type 252 | onSale 253 | slug 254 | averageRating 255 | reviewCount 256 | image { 257 | id 258 | sourceUrl 259 | srcSet 260 | altText 261 | title 262 | } 263 | galleryImages { 264 | nodes { 265 | id 266 | sourceUrl 267 | srcSet 268 | altText 269 | title 270 | } 271 | } 272 | } 273 | } 274 | variation { 275 | node { 276 | id 277 | databaseId 278 | name 279 | description 280 | type 281 | onSale 282 | price 283 | regularPrice 284 | salePrice 285 | image { 286 | id 287 | sourceUrl 288 | srcSet 289 | altText 290 | title 291 | } 292 | attributes { 293 | nodes { 294 | id 295 | name 296 | value 297 | } 298 | } 299 | } 300 | } 301 | quantity 302 | total 303 | subtotal 304 | subtotalTax 305 | } 306 | } 307 | 308 | subtotal 309 | subtotalTax 310 | shippingTax 311 | shippingTotal 312 | total 313 | totalTax 314 | feeTax 315 | feeTotal 316 | discountTax 317 | discountTotal 318 | } 319 | } 320 | `; 321 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ['./src/components/**/*.tsx', './src/pages/**/*.tsx'], 4 | theme: { 5 | extend: { 6 | backgroundImage: { 7 | 'hero-background': "url('/images/hero.jpg')", 8 | }, 9 | }, 10 | }, 11 | plugins: [], 12 | }; 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "baseUrl": ".", 18 | "paths": { 19 | "@/*": ["./src/*"] 20 | } 21 | }, 22 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 23 | "exclude": ["node_modules"] 24 | } 25 | --------------------------------------------------------------------------------