diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d9de6a7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,183 @@ +# **CONTRIBUTING GUIDE BY COFIBLOCKS** β˜• + +Thank you for your interest in contributing to CofiBlocks! We appreciate your time and effort in making our project better. + +This guide will help you get started with contributing to our project. Please note that we have a code of conduct, which we expect all contributors to adhere to. + +## πŸ“ **Contributing** + +We welcome contributions from the community! Here's how you can help: + +1. **Clone and Fork Repo**: Click the **Fork** button in the top-right corner to create a copy of the repository under your account. + + - HERE + +# + +2. **Clone the Fork:** + - Clone the forked repository to your local machine by running the following command: + + ```bash + git clone https://github.com/YOUR_USERNAME/REPOSITORY_NAME.git + ``` + + - Replace `YOUR_USERNAME` and `REPOSITORY_NAME` with your GitHub username and the repository name. + +# + +3. **Create a new branch or use the main branch:** When modifying contracts kindly make sure the formatting is correct and all tests pass successfully. + + - Create a branch name based on the type of change (e.g., `feat/name-related-issue`, `docs/name-related-issue`). + + ``` + git checkout -b branch-name + ``` + - One of ideas on how to implement it for the branch name: + + > `docs/update-readme` or `fix/bottom-bug`. + +# + +4. **Commit:** Commit your changes. + + 1. **git add (file-name)** + 2. **git commit -m "[type] description"** + + - Example: + ``` + git add Create_Documentation + + git commit -m "[docs]: update documentation" + ``` + +# + +5. **Push fork:** Push to your fork and submit a pull request on our `main` branch. Please provide us with some explanation of why you made the changes you made. For new features make sure to explain a standard use case to us. + +- Push your changes to your forked repository: + ```bash + git push origin your-branch-name + ``` + > Replace `your-branch-name` with the name of your branch. + +- Example: + ```bash + git push origin fix/bug-fix + ``` + +6. **Submit a Pull Request:** Submit a pull request to the `main` branch of the CofiBlocks repository. + + - Summit pull request + +# πŸ“Œ Update your Fork and Local Repository + +### πŸ”’ Set up the original repository as upstream (only once) +- **Check your remote.** +```bash +git remote -v +``` + +- **If you don't see `upstream`, add it.** +```bash +git remote add upstream https://github.com/Vagabonds-Labs/cofiblocks.git +``` +# + +### πŸ”© Update your Fork +1. **Bring the latest changes from the original repository.** + ```bash + git fetch upstream + ``` +2. **Switch to the `main` branch of your local repository.** + ```bash + git checkout main + ``` +3. **Merge `upstream/main` changes to your local branch.** + ```bash + git merge upstream/main + ``` +4. **Upload the changes to your fork in GitHub.** + ```bash + git push origin main + ``` +# + +### πŸ”§ Update other branches +- **Change to the branch you want to update.** +```bash +git checkout name-of-your-rama +``` +- **Take an overflow with the updated `main` branch.** +```bash +git rebase main +``` +- **Upload the changes to your fork.** +```bash +git push origin name-of-your-rama --force +``` + +## 🎯 **Common mistakes** +1. **Local changes without saving.** + - Save changes temporarily + ```bash + git stash + ``` +2. **Then update and recover your changes.** + ```bash + git stash pop + ``` +3. **Untracked files causing conflict.** + - Delete them if you don't need them + ```bash + rm filename + ``` + +# **πŸ“ Commits** + +You can do a regular commit by following the next: + +``` [type] significant message ``` + +- Learn more about conventional commits + +### Type + +Add changes you worked on the issue. + +**Examples:** + +```bash +git commit -m "[docs]: update documentation" +``` + +```bash +git commit -m "[fix]: fix bug in code" +``` + +```bash +git commit -S -m "[test]: add test case" +``` +**Other Example:** + +- If you wnat to sign your commits, you can use the `-S` flag. + +```bash +git commit -S -m "[refactor]: Changes in the code" +``` + +# **πŸ”— Branches** +1. There must be a `main` branch, used only for the releases. +2. Avoid long descriptive names for long-lived branches. +3. Use kebab-case (no CamelCase). +4. Use grouping tokens (words) at the beginning of your branch names (in a similar way to the `type` of commit). +5. Define and use short lead tokens to differentiate branches in a way that is meaningful to your workflow. +6. Use slashes to separate parts of your branch names. +7. Remove your branch after merging it if it is not important. +**Examples:** +``` +git branch -b docs/readme +git branch -b test/a-feature +git branch -b feat/sidebar +git branch -b fix/b-feature +``` +# \ No newline at end of file diff --git a/FAQ.md b/FAQ.md new file mode 100644 index 0000000..58325d8 --- /dev/null +++ b/FAQ.md @@ -0,0 +1,91 @@ +# πŸŽ‰ Frequently Asked Questions (FAQ) πŸŽ‰ + +Welcome to the **CofiBlocks FAQ** section! Here you’ll find answers to the most common questions about our platform, including its purpose, setup, features, and how to contribute. If you’re new here, this is a great place to start! πŸš€ + +## πŸ“š Sections +1. [General Questions](#general-questions) +2. [Community Questions](#community-questions) +3. [Technical Questions](#technical-questions) +4. [Contribution Questions](#contribution-questions) + +## General Questions + +### 1. How to get started? πŸ€” + +To begin understanding the **CofiBlocks** project, we recommend following these steps: + +1. **πŸ“„ Read the main README file:** + Get a comprehensive explanation of the project’s purpose and scope. You can find it [here](./README.md). + +2. **πŸ‘₯ Review the Community Guidelines:** + Familiarize yourself with the community standards by reading the [Community Guidelines](./COMMUNITY_GUIDELINES.md). + +3. **πŸ” Understand the project structure:** + The project is divided into two main parts: + - **Backend:** Learn more by reading the [backend README](./apps/snfoundry/README.md). + - **Frontend:** Discover details by checking the [frontend README](./apps/web/README.md). + +## Community Questions + +Discover how to be an active and respectful member of the **CofiBlocks** community! Learn about our values, expectations, and how to contribute positively to our mission. 🌍 + +[πŸ“– Read the full Community Guidelines here.](./COMMUNITY_GUIDELINES.md) + +### 1. 🀝 What is a Collaborative Business? +A Collaborative Business is an organization where customers, producers, and supporters become members. These members share responsibility, decision-making, and benefits (both monetary and otherwise). Our model represents an evolution of the cooperative and DAO (Decentralized Autonomous Organization) concepts, leveraging blockchain technology, smart contracts, and crypto assets (such as NFTs and social currencies). + +### 2. β˜• How CofiBlocks Business Works? + +#### 🏑 Cofi in Your Home +Subscribers receive freshly roasted coffee delivered to their doorsteps monthly, ensuring they enjoy the finest brews while supporting local producers. + +#### βš–οΈ Cofi is Fair +Producers receive fair payments upfront, allowing them to plan and understand demand ahead of time, thanks to our subscription model. + +#### 🎨 Cofi Art & NFTs +In its pilot year, CofiBlocks will create a collection of NFTs featuring original artwork, distributed via coffee labels on each bag sold. + +#### πŸͺ™ Cofi Complementary Currency +Members of the CofiBlocks pilot community on the Cambiatus app can claim their COFI tokensβ€”a complementary blockchain currency that ensures participation in the Collaborative Business's benefits. + +#### πŸ“ˆ Cofi Distribution +At the end of the first year, all CofiBlocks members will receive a percentage of the pilot's financial results in cryptocurrency based on their accumulated COFI tokens. Members will also have the opportunity to participate in decision-making for future stages of the project. + +#### πŸ”— CofiBlocks & Cambiatus + +CofiBlocks is a proud member of Cambiatus's Social Currency and Collaborative Business Ecosystem. + +[🌐 Learn More About Cambiatus](https://cambiatus.com) + +#### πŸš€ CofiBlocks & Starknet + +We're building our Web3 marketplace using Starknet technology. + +[πŸ’» Explore on GitHub](https://github.com/Vagabonds-Labs/marketplace) + +### 3. πŸ—£οΈ How Can I Join the Community Conversations? +Connect with us on our community group chats to discuss ideas, share feedback, and engage with other members! + +[πŸ’¬ Join the Community Here](https://github.com/Vagabonds-Labs/cofiblocks?tab=readme-ov-file#-join-the-community) + +## Technical Questions + +### 1. How can I run the backend locally? πŸ–₯️ + +You can run the **CofiBlocks** backend locally by following the steps in the [backend README](./apps/snfoundry/README.md). + +### 2. How can I run the frontend locally? 🌐 + +To run the **CofiBlocks** frontend locally, follow the steps in the [frontend README](./apps/web/README.md). + +### 3. Where can I find development resources? πŸ“š + +Explore all the resources, tools, and guides for developers working on **CofiBlocks** in our [Development Resources](https://github.com/Vagabonds-Labs/cofiblocks?tab=readme-ov-file#-development-resources) section. + +## Contribution Questions + +Find everything you need to know about contributing to **CofiBlocks**, from submitting pull requests to following our coding standards. πŸ“ˆ + +[Check the Contribution Guide here.](./pull_request_template.md) + +We’re excited to have you on board! Let’s build something amazing together. πŸ’ͺπŸ’₯ \ No newline at end of file diff --git a/README.md b/README.md index 22b7593..8e9576a 100644 --- a/README.md +++ b/README.md @@ -1,170 +1,184 @@ -# CofiBlocks - La Comunidad del CafΓ© β˜•οΈ +# **CofiBlocks - La Comunidad del CafΓ© β˜•οΈ** -CofiBlocks is the first Collaborative Business connecting traditional coffee-growing communities in Costa Rica and worldwide directly with coffee lovers using Starknet blockchain technology. Our mission is to distribute benefits among all members, ensuring fair trade, community engagement, and technological innovation. +CofiBlocks is the first Collaborative Business connecting traditional coffee-growing communities in Costa Rica and worldwide directly with coffee lovers using StarkNet blockchain technology. Our mission is to distribute benefits among all members, ensuring fair trade, community engagement, and technological innovation. -## 🌍 What is a Collaborative Business? +![Build Status](https://img.shields.io/badge/build-passing-brightgreen) ![License](https://img.shields.io/badge/license-MIT-blue) -A Collaborative Business is an organization where customers, producers, and supporters become members. These members share responsibility, decision-making, and benefits (both monetary and otherwise). Our model represents an evolution of the cooperative and DAO (Decentralized Autonomous Organization) concepts, leveraging blockchain technology, smart contracts, and crypto assets (such as NFTs and social currencies). +--- -## 🌟 How Does It Work? +## πŸš€ **Roadmap** -### Cofi in Your Home -Subscribers receive freshly roasted coffee delivered to their doorsteps monthly, ensuring they enjoy the finest brews while supporting local producers. +| **Season** | **Details** | +|--------------------|-----------------------------------------------------------------------------| +| **2022-2023** | Thank you to everyone who participated in our first season featuring coffee from the slopes of VolcΓ‘n PoΓ‘s. | +| **2024-2025** | Launching our second season with coffee from additional regions of Costa Rica. | -### Cofi is Fair -Producers receive fair payments upfront, allowing them to plan and understand demand ahead of time, thanks to our subscription model. +**Want to bring CofiBlocks to your region?** +πŸ“§ [Contact Us](mailto:info@cofiblocks.com) -### Cofi Art & NFTs -In its pilot year, CofiBlocks will create a collection of NFTs featuring original artwork, distributed via coffee labels on each bag sold. +--- -### Cofi Complementary Currency -Members of the CofiBlocks pilot community on the Cambiatus app can claim their COFI tokensβ€”a complementary blockchain currency that ensures participation in the Collaborative Business's benefits. +## 🌎 **Movement** -### Cofi Distribution -At the end of the first year, all CofiBlocks members will receive a percentage of the pilot's financial results in cryptocurrency based on their accumulated COFI tokens. Members will also have the opportunity to participate in decision-making for future stages of the project. +CofiBlocks is more than just a coffee marketplace; it's a movement for a more equitable and sustainable coffee future. With a strong foundation and a passionate community, we aim to transform the way coffee is produced, distributed, and enjoyed worldwide. Whether you're a coffee enthusiast or a blockchain believer, join us on this journey! -## πŸ”— CofiBlocks & Cambiatus +--- -CofiBlocks is a proud member of Cambiatus's Social Currency and Collaborative Business Ecosystem. +## πŸ“Œ **Fundamental Links** -[Learn More About Cambiatus](https://cambiatus.com) +- [Learning about Modern Collaborative Businesses with CofiBlocks](https://medium.com/cambiatus/conociendo-de-modernos-negocios-colaborativos-con-cofiblocks-88ed3ddfa88a) +- [CofiBlocks: Innovating the Coffee Industry in Costa Rica with Web3](https://mirror.xyz/0xF574753688ABf9740660DFb02E84E4599CA6Eb87/QneFnlPqTRuV_3jt7Kd6foBfFokOKN6zNcJCqpj_xkw) +- [Pitch Deck](https://docs.google.com/presentation/d/16zPeDC-6fMaCRRpaQCTPPIn_ZKdI0d9ZArkTxd2wmAA/edit#slide=id.p1) -## πŸš€ CofiBlocks & Starknet +--- -We're building our Web3 marketplace using Starknet technology. +## πŸ‘₯ **Meet the Team** -[Explore on GitHub](https://github.com/Vagabonds-Labs/marketplace) +### β˜• **Founders** +- **Omar Hurtado Munguia**: From coffee picker to coffee tour guide, Omar's journey inspired CofiBlocks. [Watch his story](https://www.youtube.com/watch?v=OjymLl3zKss). +- **Karla CΓ³rdoba Brenes**: Blockchain expert focused on impact-driven solutions. +- **Ranulfo Paiva Sobrinho**: Blockchain developer with expertise in collaborative economies. -## πŸ› οΈ Getting Started +### **Maintainers** +- **Alberto - Brolag** + - [GitHub](https://github.com/brolag) | [Twitter](https://x.com/brolag) +- **Erick - Evgongora** + - [GitHub](https://github.com/evgongora) | [Twitter](https://x.com/3rickDev) +- **Randall Valenciano** + - [GitHub](https://github.com/rvalenciano) | [Twitter](https://x.com/Ravf226) -### Prerequisites +--- + +## πŸŽ‰ **Join the Community** + +- 🌐 [Website](https://cofiblocks.com) +- 🐦 [Follow us on Twitter](https://x.com/cofiblocks) +- πŸ’¬ [Telegram for ODBoost](https://t.me/cofiblocksodhack) +- πŸ’» [Telegram for Developers](https://t.me/cofiblocksodhack) + +--- + +## πŸ› οΈ **Getting Started** + +### **Prerequisites** - Node.js (>= 18) - Bun package manager (bun@1.1.24) - Prisma - -### Installation - +### **Crate file** +`/cofiblocks/apps/web/sql/init.sql` +- And REPLACE MYSQL_USER with your mysql user in the .env +```bash +GRANT CREATE ON *.* TO ''@'%'; +GRANT ALL PRIVILEGES ON *.* TO ''@'%'; +``` +### **Docker-compose.yml** +Add this to your docker-compose.yml file: +```bash +version: '3.8' +services: + db: + image: mysql:8.4 + container_name: mysql + restart: always + env_file: + - ./apps/web/.env + ports: + - '3306:3306' + healthcheck: + test: ['CMD-SHELL', 'mysqladmin ping -h 127.0.0.1 --password="$$(cat /run/secrets/db-password)" --silent'] + interval: 3s + retries: 5 + start_period: 30s + volumes: + - mysql-data:/var/lib/mysql + - ./apps/web/sql/init.sql:/docker-entrypoint-initdb.d/init.sql +volumes: + mysql-data: +``` +### **Installation** 1. Clone the repository: ```bash - git clone https://github.com/Vagabonds-Labs/marketplace.git - cd marketplace + git clone https://github.com/Vagabonds-Labs/cofiblocks.git + cd cofiblocks ``` - + - Run `docker compose up`, to access the database. 2. Install dependencies: ```bash bun install ``` - -3. Generate the Prisma client: +3. Rename + ```bash + mv .env.example to .env + ``` + **And add this in your .env file:** + ```bash + MYSQL_ROOT_PASSWORD= + MYSQL_DATABASE= + MYSQL_USER= + MYSQL_PASSWORD= + DATABASE_URL="mysql://${MYSQL_USER}:${MYSQL_PASSWORD + }@localhost:3306/${MYSQL_DATABASE}? + connect_timeout=10" + ``` + **Important:** + - Add and run `docker compose up` in this part. +4. Generate the Prisma client: ```bash bun prisma generate ``` - -4. Run the development server: +5. Run the development server: ```bash bun turbo dev ``` - -### Project Structure - +**Make sure the values in the .env file are configured correctly for your environment.** +### **Project Structure** The project is organized using workspaces: - - `apps/` - Contains the main web application. - `packages/` - Shared packages and utilities. Key scripts include: - - `build`: Build the project. - `dev`: Start the development server. - `db:migrate`: Apply database migrations. - `db:seed`: Seed the database with initial data. -## Contributing - -We welcome contributions from the community! Here's how you can help: - -1. Fork the repository and create your branch: +### **Key Technologies** +1. **StarkNet** ```bash - git checkout -b feature/amazing-feature + StarkNet is a decentralized, permissionless Layer 2 solution for Ethereum. It uses ZK-STARKs (zero-knowledge proofs) to enable fast and cost-efficient transactions while ensuring security and scalability. Developers can deploy smart contracts, and users benefit from significantly reduced gas fees compared to Ethereum's mainnet. ``` - -2. Commit your changes: +2. **Prisma** ```bash - git commit -m "Add amazing feature" + Prisma is a modern database toolkit for Node.js and TypeScript. It provides an ORM (Object-Relational Mapping) that simplifies working with databases, allowing developers to define models and query data in a type-safe way. Prisma supports multiple databases, including PostgreSQL, MySQL, and MongoDB ``` - -3. Push to the branch: +3. **Bun** ```bash - git push origin feature/amazing-feature + Bun is an all-in-one JavaScript runtime that competes with Node.js and Deno. It’s built for performance and includes a fast bundler, transpiler, and package manager. Bun aims to speed up development workflows, reduce dependency on third-party tools, and execute JavaScript and TypeScript quickly. It’s designed to handle server-side apps, scripts, and front-end tooling. ``` - -4. Open a Pull Request. - -## Code of Conduct - +## πŸ“š **Code of Conduct** We are committed to creating a welcoming and inclusive environment. Please read our [Community Guidelines](COMMUNITY_GUIDELINES.md) to ensure a positive experience for everyone involved. -## πŸŽ‰ Join the Community - -- Website: [CofiBlocks Website](https://cofiblocks.com) -- Twitter: [Follow us on Twitter](https://twitter.com/cofiblocks) - -## πŸš€ Roadmap - -### 2022-2023 Season -Thank you to everyone who participated in our first season and enjoyed coffee from the slopes of VolcΓ‘n PoΓ‘s. - -### 2024-2025 Season -We are preparing to launch our second season, featuring coffee from additional coffee-growing regions of Costa Rica. - -Want to bring CofiBlocks to your region? [Contact Us](mailto:info@cofiblocks.com) - -## πŸ‘₯ Meet the Team - -### Omar Hurtado Munguia -**Co-founder** - -Omar's journey from coffee picker to coffee tour guide in PoΓ‘s de Alajuela inspired the creation of CofiBlocks. He brings firsthand knowledge of the challenges small producers face. - -### Karla CΓ³rdoba Brenes -**Co-founder** - -Karla contributes her extensive experience in blockchain technology and impact-driven solutions. - -### Ranulfo Paiva Sobrinho -**Co-founder** - -Ranulfo's background in blockchain development and collaborative economies helps drive the technical and organizational aspects of CofiBlocks. - -## πŸ€– Development Resources - -### CofiBlocks Dev Assistant - -We've created a custom GPT to assist with development tasks related to CofiBlocks. This AI-powered assistant is designed to help with coding, answer questions about our tech stack, and provide guidance on best practices. - -Access the CofiBlocks Dev Assistant here: [CofiBlocks Dev Assistant](https://chatgpt.com/g/g-JIRAV36d5-cofiblocks-dev-assistant) - -Features: -- Coding assistance for our tech stack (Next.js, TypeScript, TailwindCSS, etc.) -- Guidance on project structure and best practices -- Help with Starknet and blockchain-related development -- Quick answers to common development questions +## πŸ“š **Development Resources** -Feel free to use this resource during your development process to streamline your workflow and get quick answers to your questions. +### **CofiBlocks Dev Assistant** +Our custom GPT assistant helps with coding, StarkNet guidance, and blockchain-related development. [Access it here](https://chatgpt.com/g/g-JIRAV36d5-cofiblocks-dev-assistant). +### **Prompt Guide** +Explore our detailed [Prompt Guide](docs/prompt-guide.md) to streamline your workflow. -### Prompt Guide +--- -To streamline our development process and maintain consistency across the project, we've created a comprehensive prompt guide. This guide covers various custom prompts designed to assist with component generation, code-related tasks, and information retrieval. +## πŸ› οΈ **Key Technologies** -Key features of our prompt guide include: +- **StarkNet**: Scalable Layer 2 solution for Ethereum with reduced gas fees. +- **Prisma**: Type-safe, modern ORM for working with databases. +- **Bun**: High-performance runtime for JavaScript/TypeScript. -- Instructions for using SudoLang, a powerful pseudocode language for AI collaboration -- Detailed usage guidelines for our custom prompts: - - V0PromptWithComponent for generating v0.dev prompts - - TailwindReactComponentGenerator for creating React components with Tailwind CSS - - PerplexityBot for efficient information searches -- Best practices for prompt usage and troubleshooting tips +--- -For full details and usage instructions, please refer to our [Prompt Guide](docs/prompt-guide.md). +## πŸ’‘ **Contributions** +We welcome contributors! Here's how you can help: +1. Review [open issues](https://github.com/Vagabonds-Labs/cofiblocks/issues). +2. Submit pull requests with detailed explanations. +3. Suggest new features or improvements. diff --git a/apps/snfoundry/.env.example b/apps/snfoundry/.env.example index 464a702..169d21e 100644 --- a/apps/snfoundry/.env.example +++ b/apps/snfoundry/.env.example @@ -8,3 +8,6 @@ PRIVATE_KEY_SEPOLIA= RPC_URL_SEPOLIA=https://starknet-sepolia.public.blastapi.io/rpc/v0_7 ACCOUNT_ADDRESS_SEPOLIA= + +# IPFS +TOKEN_METADATA_URL="ipfs://test/{id}.json" diff --git a/apps/snfoundry/README.md b/apps/snfoundry/README.md new file mode 100644 index 0000000..3cdc571 --- /dev/null +++ b/apps/snfoundry/README.md @@ -0,0 +1,73 @@ +# CofiBlocks Contracts + +## Deployment Guide + +Follow these steps to deploy the CofiBlocks contracts on the StarkNet network. + +### 1. Configure the `.env` file + +Set the following environment variables in your `.env` file with the details of a prefunded wallet. This wallet will act as the admin address: + +- **`PRIVATE_KEY_SEPOLIA`** – The private key of the admin wallet. +- **`ACCOUNT_ADDRESS_SEPOLIA`** – The address of the admin wallet. +- **`TOKEN_METADATA_URL`** – The IPFS URL to serve as the token metadata. + + The URL should follow the format: `ipfs:///{id}.json`, where `{id}` will be dynamically replaced with the actual token ID by clients when fetching metadata. + + **Example:** + ``` + ipfs://bafybeihevtihdmcjkdh6sjdtkbdjnngbfdlr3tjk2dfmvd3demdm57o3va/{id}.json + ``` + For token ID `1`, the resulting URL will be: + ``` + ipfs://bafybeihevtihdmcjkdh6sjdtkbdjnngbfdlr3tjk2dfmvd3demdm57o3va/1.json + ``` + +### 2. Install dependencies + +Run the following command to install project dependencies: +```bash +bun i +``` + +### 3. Deploy the contracts + +To deploy the contracts on the Sepolia testnet, run: +```bash +bun deploy:sepolia +``` + +This command will: +- Deploy both the **CofiCollections** and **Marketplace** contracts. +- Set the **Marketplace** contract as the minter in the **CofiCollection** contract. +- Set the `base_uri` in the **CofiCollection** contract using the `TOKEN_METADATA_URL` value from the `.env` file. + +### 4. Retrieve deployed contract addresses + +Once the deployment is complete, the contract addresses will be available in: +- The terminal output. +- The file located at: `deployments/sepolia_latest.json`. + + +## Testing +To test the contracts, follow these steps. + +1. Go to contracts folder +```bash +cd contracts +``` + +2. Run test command +```bash +scarb test +``` + +## Note +When updating the contracts, you need to update them in the web app too. To do that follow this steps + +1. Go to page https://scaffold-stark-demo.vercel.app/configure +2. Copy/paste the contract address in the Address field. Use this valid names: CofiCollection, Marketplace. +3. Click on download Contract file. It will give you a json file with the contract abi and contract address. +4. Update the file https://github.com/Vagabonds-Labs/cofiblocks/blob/main/apps/web/src/contracts/configExternalContracts.ts +with the new data (just copy/paste the contract metadata in the corresponding field without affecting other contracts metadata). +5. Do the same for Marketplace contract. diff --git a/apps/snfoundry/contracts/Scarb.lock b/apps/snfoundry/contracts/Scarb.lock index d770b1d..3a0108a 100644 --- a/apps/snfoundry/contracts/Scarb.lock +++ b/apps/snfoundry/contracts/Scarb.lock @@ -3,7 +3,7 @@ version = 1 [[package]] name = "contracts" -version = "0.2.0" +version = "0.3.0" dependencies = [ "openzeppelin", "snforge_std", diff --git a/apps/snfoundry/contracts/Scarb.toml b/apps/snfoundry/contracts/Scarb.toml index 03a77ea..bc717d8 100644 --- a/apps/snfoundry/contracts/Scarb.toml +++ b/apps/snfoundry/contracts/Scarb.toml @@ -1,7 +1,7 @@ [package] name = "contracts" edition = "2023_11" -version = "0.2.0" +version = "0.3.0" # See more keys and their definitions at https://docs.swmansion.com/scarb/docs/reference/manifest.html @@ -13,9 +13,17 @@ starknet = "2.8.2" [dev-dependencies] assert_macros = "2.8.2" +[scripts] +test = "snforge test" + [[target.starknet-contract]] casm = true sierra = true +[[tool.snforge.fork]] +name = "SEPOLIA_LATEST" +url = "https://starknet-sepolia.public.blastapi.io/rpc/v0_7" +block_id.tag = "latest" + [tool.fmt] sort-module-level-items = true \ No newline at end of file diff --git a/apps/snfoundry/contracts/src/cofi_collection.cairo b/apps/snfoundry/contracts/src/cofi_collection.cairo index a502076..166b782 100644 --- a/apps/snfoundry/contracts/src/cofi_collection.cairo +++ b/apps/snfoundry/contracts/src/cofi_collection.cairo @@ -175,6 +175,9 @@ pub trait ICofiCollection { /// Unpauses all token transfers. fn unpause(ref self: TContractState); + + // Update minter after deployment + fn set_minter(ref self: TContractState, minter: ContractAddress); } #[starknet::contract] @@ -256,6 +259,7 @@ mod CofiCollection { self.accesscontrol._grant_role(MINTER_ROLE, minter); self.accesscontrol._grant_role(URI_SETTER_ROLE, uri_setter); self.accesscontrol._grant_role(UPGRADER_ROLE, upgrader); + self.erc1155._set_base_uri("ipfs://"); } impl ERC1155HooksImpl of ERC1155Component::ERC1155HooksTrait { @@ -374,5 +378,11 @@ mod CofiCollection { self.accesscontrol.assert_only_role(URI_SETTER_ROLE); self.erc1155._set_base_uri(base_uri); } + + #[external(v0)] + fn set_minter(ref self: ContractState, minter: ContractAddress) { + self.accesscontrol.assert_only_role(DEFAULT_ADMIN_ROLE); + self.accesscontrol._grant_role(MINTER_ROLE, minter); + } } } diff --git a/apps/snfoundry/contracts/src/lib.cairo b/apps/snfoundry/contracts/src/lib.cairo index a68bb7d..6db3794 100644 --- a/apps/snfoundry/contracts/src/lib.cairo +++ b/apps/snfoundry/contracts/src/lib.cairo @@ -1,7 +1,10 @@ mod cofi_collection; +mod marketplace; +#[cfg(test)] mod test { mod test_cofi_collection; + mod test_marketplace; } mod mock_contracts { diff --git a/apps/snfoundry/contracts/src/marketplace.cairo b/apps/snfoundry/contracts/src/marketplace.cairo new file mode 100644 index 0000000..6956a16 --- /dev/null +++ b/apps/snfoundry/contracts/src/marketplace.cairo @@ -0,0 +1,485 @@ +// SPDX-License-Identifier: MIT +// Compatible with OpenZeppelin Contracts for Cairo ^0.15.0 + +use starknet::ContractAddress; + +#[starknet::interface] +pub trait IMarketplace { + fn assign_seller_role(ref self: ContractState, assignee: ContractAddress); + fn assign_consumer_role(ref self: ContractState, assignee: ContractAddress); + fn assign_admin_role(ref self: ContractState, assignee: ContractAddress); + fn buy_product(ref self: ContractState, token_id: u256, token_amount: u256); + fn buy_products(ref self: ContractState, token_ids: Span, token_amount: Span); + fn create_product( + ref self: ContractState, initial_stock: u256, price: u256, data: Span + ) -> u256; + fn create_products( + ref self: ContractState, initial_stock: Span, price: Span + ) -> Span; + fn delete_product(ref self: ContractState, token_id: u256); + fn delete_products(ref self: ContractState, token_ids: Span); + fn claim_balance(self: @ContractState, producer: ContractAddress) -> u256; + fn claim(ref self: ContractState); +} + +#[starknet::contract] +mod Marketplace { + use contracts::cofi_collection::{ICofiCollectionDispatcher, ICofiCollectionDispatcherTrait}; + use openzeppelin::access::accesscontrol::AccessControlComponent; + use openzeppelin::access::accesscontrol::DEFAULT_ADMIN_ROLE; + use openzeppelin::introspection::src5::SRC5Component; + use openzeppelin::token::erc1155::erc1155_receiver::ERC1155ReceiverComponent; + use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; + use openzeppelin::upgrades::UpgradeableComponent; + use starknet::event::EventEmitter; + use starknet::storage::Map; + use starknet::{ + ContractAddress, get_caller_address, get_contract_address, contract_address_const + }; + + component!( + path: ERC1155ReceiverComponent, storage: erc1155_receiver, event: ERC1155ReceiverEvent + ); + component!(path: SRC5Component, storage: src5, event: SRC5Event); + component!(path: AccessControlComponent, storage: accesscontrol, event: AccessControlEvent); + component!(path: UpgradeableComponent, storage: upgradeable, event: UpgradeableEvent); + + // Role definition + const PRODUCER: felt252 = selector!("PRODUCER"); + const CONSUMER: felt252 = selector!("CONSUMER"); + + // ERC1155Receiver + #[abi(embed_v0)] + impl ERC1155ReceiverImpl = + ERC1155ReceiverComponent::ERC1155ReceiverImpl; + impl ERC1155ReceiverInternalImpl = ERC1155ReceiverComponent::InternalImpl; + + // SRC5 + #[abi(embed_v0)] + impl SRC5Impl = SRC5Component::SRC5Impl; + + // Access Control + #[abi(embed_v0)] + impl AccessControlImpl = + AccessControlComponent::AccessControlImpl; + impl AccessControlInternalImpl = AccessControlComponent::InternalImpl; + + // Upgradeable + impl UpgradeableInternalImpl = UpgradeableComponent::InternalImpl; + + const STRK_TOKEN_ADDRESS: felt252 = + 0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d; + + #[storage] + struct Storage { + #[substorage(v0)] + erc1155_receiver: ERC1155ReceiverComponent::Storage, + #[substorage(v0)] + src5: SRC5Component::Storage, + #[substorage(v0)] + accesscontrol: AccessControlComponent::Storage, + #[substorage(v0)] + upgradeable: UpgradeableComponent::Storage, + market_fee: u256, + listed_product_stock: Map, + listed_product_price: Map, + seller_products: Map, + cofi_collection_address: ContractAddress, + claim_balances: Map, + current_token_id: u256, + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + ERC1155ReceiverEvent: ERC1155ReceiverComponent::Event, + #[flat] + SRC5Event: SRC5Component::Event, + #[flat] + AccessControlEvent: AccessControlComponent::Event, + #[flat] + UpgradeableEvent: UpgradeableComponent::Event, + DeleteProduct: DeleteProduct, + CreateProduct: CreateProduct, + UpdateStock: UpdateStock, + BuyProduct: BuyProduct, + BuyBatchProducts: BuyBatchProducts, + PaymentSeller: PaymentSeller, + } + // Emitted when a product is unlisted from the Marketplace + #[derive(Drop, PartialEq, starknet::Event)] + struct DeleteProduct { + token_id: u256, + } + + // Emitted when a product is listed to the Marketplace + #[derive(Drop, PartialEq, starknet::Event)] + struct CreateProduct { + token_id: u256, + initial_stock: u256, + } + + // Emitted when the stock of a product is updated + #[derive(Drop, PartialEq, starknet::Event)] + struct UpdateStock { + token_id: u256, + new_stock: u256, + } + + // Emitted when a product is bought from the Marketplace + #[derive(Drop, PartialEq, starknet::Event)] + struct BuyProduct { + token_id: u256, + amount: u256, + price: u256, + } + + // Emitted when a batch of products is bought from the Marketplace + #[derive(Drop, PartialEq, starknet::Event)] + struct BuyBatchProducts { + token_ids: Span, + token_amount: Span, + total_price: u256, + } + + // Emitted when the seller gets their tokens from a sell + #[derive(Drop, PartialEq, starknet::Event)] + struct PaymentSeller { + token_ids: Span, + seller: ContractAddress, + payment: u256, + } + + /// + /// Constructor. + /// # Arguments + /// * `cofi_collection_address` - The address of the CofiCollection contract + /// * `admin` - The address of the admin role + /// * `market_fee` - The fee that the marketplace will take from the sales + /// * `base_uri` - The base uri for the NFTs metadata. Should contain `{id}` so that metadata + /// gets + /// replace per each token id. Example: https://example.com/metadata/{id}.json + /// + #[constructor] + fn constructor( + ref self: ContractState, + cofi_collection_address: ContractAddress, + admin: ContractAddress, + market_fee: u256, + ) { + self.erc1155_receiver.initializer(); + self.accesscontrol.initializer(); + self.accesscontrol._grant_role(DEFAULT_ADMIN_ROLE, admin); + self.cofi_collection_address.write(cofi_collection_address); + self.market_fee.write(market_fee); + self.current_token_id.write(1); + } + + #[abi(embed_v0)] + impl MarketplaceImpl of super::IMarketplace { + fn assign_seller_role(ref self: ContractState, assignee: ContractAddress) { + self.accesscontrol.assert_only_role(DEFAULT_ADMIN_ROLE); + self.accesscontrol._grant_role(PRODUCER, assignee); + } + + fn assign_consumer_role(ref self: ContractState, assignee: ContractAddress) { + self.accesscontrol.assert_only_role(DEFAULT_ADMIN_ROLE); + self.accesscontrol._grant_role(CONSUMER, assignee); + } + + fn assign_admin_role(ref self: ContractState, assignee: ContractAddress) { + self.accesscontrol.assert_only_role(DEFAULT_ADMIN_ROLE); + self.accesscontrol._grant_role(DEFAULT_ADMIN_ROLE, assignee); + } + + fn buy_product(ref self: ContractState, token_id: u256, token_amount: u256) { + let stock = self.listed_product_stock.read(token_id); + assert(stock >= token_amount, 'Not enough stock'); + + let buyer = get_caller_address(); + let contract_address = get_contract_address(); + let strk_token_dispatcher = IERC20Dispatcher { + contract_address: contract_address_const::() + }; + // Get payment from buyer + let mut producer_fee = self.listed_product_price.read(token_id) * token_amount; + let mut total_price = producer_fee + + self.calculate_fee(producer_fee, self.market_fee.read()); + assert( + strk_token_dispatcher.balance_of(get_caller_address()) >= total_price, + 'insufficient funds' + ); + assert( + strk_token_dispatcher.allowance(buyer, contract_address) >= total_price, + 'insufficient allowance' + ); + strk_token_dispatcher.transfer_from(buyer, contract_address, total_price); + + // Transfer the nft products + let cofi_collection = ICofiCollectionDispatcher { + contract_address: self.cofi_collection_address.read() + }; + cofi_collection + .safe_transfer_from( + contract_address, buyer, token_id, token_amount, array![0].span() + ); + + // Update stock + let new_stock = stock - token_amount; + self.update_stock(token_id, new_stock); + + self.emit(BuyProduct { token_id, amount: token_amount, price: total_price }); + if (!self.accesscontrol.has_role(CONSUMER, get_caller_address())) { + self.accesscontrol._grant_role(CONSUMER, get_caller_address()); + } + + // Send payment to the producer + let seller_address = self.seller_products.read(token_id); + self + .claim_balances + .write(seller_address, self.claim_balances.read(seller_address) + producer_fee); + let token_ids = array![token_id].span(); + self.emit(PaymentSeller { token_ids, seller: seller_address, payment: producer_fee }); + } + + fn buy_products(ref self: ContractState, token_ids: Span, token_amount: Span) { + assert(token_ids.len() > 0, 'No products to buy'); + assert(token_ids.len() == token_amount.len(), 'wrong length of arrays'); + let buyer = get_caller_address(); + let contract_address = get_contract_address(); + + // Check if the buyer has enough funds to buy all the products + let mut token_idx = 0; + let mut producer_fee = 0_u256; + let mut producers_found = array![]; + loop { + if token_idx == token_ids.len() { + break; + } + let stock = self.listed_product_stock.read(*token_ids.at(token_idx)); + assert(stock > 0, 'Product not available'); + let token_amount = *token_amount.at(token_idx); + assert(stock >= token_amount, 'Not enough stock'); + producer_fee += self.listed_product_price.read(*token_ids.at(token_idx)) + * token_amount; + + let producer = self.seller_products.read(*token_ids.at(token_idx)); + if producers_found.len() == 0 { + producers_found.append(producer); + } + assert(*producers_found.at(0) == producer, 'Different producers'); + token_idx += 1; + }; + + // Transfer the funds + let total_price = producer_fee + + self.calculate_fee(producer_fee, self.market_fee.read()); + let strk_token_dispatcher = IERC20Dispatcher { + contract_address: contract_address_const::() + }; + assert(strk_token_dispatcher.balance_of(buyer) >= total_price, 'insufficient funds'); + strk_token_dispatcher.transfer_from(buyer, contract_address, total_price); + + // Transfer the nft products + let cofi_collection = ICofiCollectionDispatcher { + contract_address: self.cofi_collection_address.read() + }; + cofi_collection + .safe_batch_transfer_from( + contract_address, buyer, token_ids, token_amount, array![0].span() + ); + + self.emit(BuyBatchProducts { token_ids, token_amount, total_price }); + // Update stock for products + let mut token_idx = 0; + loop { + if token_idx == token_ids.len() { + break; + } + let stock = self.listed_product_stock.read(*token_ids.at(token_idx)); + let token_amount = *token_amount.at(token_idx); + let new_stock = stock - token_amount; + self.update_stock(*token_ids.at(token_idx), new_stock); + token_idx += 1; + }; + + if (!self.accesscontrol.has_role(CONSUMER, buyer)) { + self.accesscontrol._grant_role(CONSUMER, buyer); + } + + // Send payment to the producer + let seller_address = *producers_found.at(0); + self + .claim_balances + .write(seller_address, self.claim_balances.read(seller_address) + producer_fee); + self.emit(PaymentSeller { token_ids, seller: seller_address, payment: producer_fee }); + } + + /// + /// Adds a new product to the marketplace + /// Arguments: + /// * `initial_stock` - The amount of stock that the product will have + /// * `price` - The price of the product per unity expresed in fri (1e-18 strk) + /// * `data` - Additional context or metadata for the token transfer process + fn create_product( + ref self: ContractState, initial_stock: u256, price: u256, data: Span + ) -> u256 { + self.accesscontrol.assert_only_role(PRODUCER); + let token_id = self.current_token_id.read(); + let cofi_collection = ICofiCollectionDispatcher { + contract_address: self.cofi_collection_address.read() + }; + cofi_collection.mint(get_contract_address(), token_id, initial_stock, data); + + let producer = get_caller_address(); + + self.current_token_id.write(token_id + 1); + self.initialize_product(token_id, producer, initial_stock, price); + token_id + } + + fn create_products( + ref self: ContractState, initial_stock: Span, price: Span + ) -> Span { + assert(initial_stock.len() == price.len(), 'wrong len of arrays'); + self.accesscontrol.assert_only_role(PRODUCER); + let producer = get_caller_address(); + let cofi_collection = ICofiCollectionDispatcher { + contract_address: self.cofi_collection_address.read() + }; + + // Create token ids and mint the nfts + let mut token_ids = array![]; + let current_token_id = self.current_token_id.read(); + let mut token_idx = 0; + loop { + if token_idx == initial_stock.len() { + break; + } + token_ids.append(current_token_id + token_idx.into()); + token_idx += 1; + }; + cofi_collection + .batch_mint( + get_contract_address(), token_ids.span(), initial_stock, array![0].span() + ); + self.current_token_id.write(current_token_id + initial_stock.len().into()); + + // Initialize the products + token_idx = 0; + loop { + if token_idx == initial_stock.len() { + break; + } + self + .initialize_product( + *token_ids.at(token_idx), + producer, + *initial_stock.at(token_idx), + *price.at(token_idx) + ); + token_idx += 1; + }; + token_ids.span() + } + + fn delete_product(ref self: ContractState, token_id: u256) { + self.accesscontrol.assert_only_role(PRODUCER); + let producer = get_caller_address(); + assert(self.seller_products.read(token_id) == producer, 'Not your product'); + + let cofi_collection = ICofiCollectionDispatcher { + contract_address: self.cofi_collection_address.read() + }; + let token_holder = get_contract_address(); + let amount_tokens = cofi_collection.balance_of(token_holder, token_id); + self.update_stock(token_id, 0); + cofi_collection.burn(token_holder, token_id, amount_tokens); + self.emit(DeleteProduct { token_id }); + } + + fn delete_products(ref self: ContractState, token_ids: Span) { + self.accesscontrol.assert_only_role(PRODUCER); + let producer = get_caller_address(); + let mut token_idx = 0; + // Check that all nfts belongs to the caller + loop { + if token_idx == token_ids.len() { + break; + } + let token_id = *token_ids.at(token_idx); + assert(self.seller_products.read(token_id) == producer, 'Not your product'); + token_idx += 1; + }; + + // Burn nfts + token_idx = 0; + let token_holder = get_contract_address(); + let cofi_collection = ICofiCollectionDispatcher { + contract_address: self.cofi_collection_address.read() + }; + loop { + if token_idx == token_ids.len() { + break; + } + let token_id = *token_ids.at(token_idx); + let amount_tokens = cofi_collection.balance_of(token_holder, token_id); + self.update_stock(token_id, 0); + cofi_collection.burn(token_holder, token_id, amount_tokens); + self.emit(DeleteProduct { token_id }); + token_idx += 1; + }; + } + + fn claim_balance(self: @ContractState, producer: ContractAddress) -> u256 { + self.claim_balances.read(producer) + } + + fn claim(ref self: ContractState) { + self.accesscontrol.assert_only_role(PRODUCER); + let producer = get_caller_address(); + let claim_balance = self.claim_balances.read(producer); + assert(claim_balance > 0, 'No tokens to claim'); + let strk_token_dispatcher = IERC20Dispatcher { + contract_address: contract_address_const::() + }; + let transfer = strk_token_dispatcher.transfer(producer, claim_balance); + assert(transfer, 'Error claiming'); + + self.claim_balances.write(producer, 0); + } + } + + #[generate_trait] + impl InternalImpl of InternalTrait { + fn initialize_product( + ref self: ContractState, + token_id: u256, + producer: ContractAddress, + stock: u256, + price: u256 + ) { + //let product = Product { stock, price }; + self.seller_products.write(token_id, producer); + self.listed_product_stock.write(token_id, stock); + self.listed_product_price.write(token_id, price); + self.emit(CreateProduct { token_id, initial_stock: stock }); + } + + fn update_stock(ref self: ContractState, token_id: u256, new_stock: u256) { + self.listed_product_stock.write(token_id, new_stock); + self.emit(UpdateStock { token_id, new_stock }); + } + + // Amount is the total amount + // BPS is the percentage you want to calculate. (Example: 2.5% = 250bps, 7,48% = 748bps) + // Use example: + // Calculate the 3% fee of 250 STRK + // calculate_fee(250, 300) = 7.5 + fn calculate_fee(ref self: ContractState, amount: u256, bps: u256) -> u256 { + assert((amount * bps) >= 10_000, 'Fee too low'); + amount * bps / 10_000 + } + } +} diff --git a/apps/snfoundry/contracts/src/test/test_marketplace.cairo b/apps/snfoundry/contracts/src/test/test_marketplace.cairo new file mode 100644 index 0000000..272deab --- /dev/null +++ b/apps/snfoundry/contracts/src/test/test_marketplace.cairo @@ -0,0 +1,377 @@ +mod test_marketplace { + use contracts::cofi_collection::ICofiCollectionDispatcher; + use contracts::cofi_collection::ICofiCollectionDispatcherTrait; + use contracts::marketplace::{IMarketplaceDispatcher, IMarketplaceDispatcherTrait}; + use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; + + use openzeppelin::utils::serde::SerializedAppend; + use snforge_std::{declare, ContractClassTrait, DeclareResultTrait, start_cheat_caller_address}; + use starknet::ContractAddress; + use starknet::syscalls::call_contract_syscall; + + fn OWNER() -> ContractAddress { + starknet::contract_address_const::<'OWNER'>() + } + + const STRK_TOKEN_ADDRESS: felt252 = + 0x04718f5a0fc34cc1af16a1cdee98ffb20c31f5cd61d6ab07201858f4287c938d; + + const STRK_TOKEN_MINTER_ADDRESS: felt252 = + 0x0594c1582459ea03f77deaf9eb7e3917d6994a03c13405ba42867f83d85f085d; + + const ONE_E18: u256 = 1000000000000000000_u256; + const MARKET_FEE: u256 = 250_u256; // 2.5% + + fn deploy_receiver() -> ContractAddress { + let contract = declare("Receiver").unwrap().contract_class(); + let calldata = array![]; + let (contract_address, _) = contract.deploy(@calldata).unwrap(); + contract_address + } + + fn deploy_cofi_collection() -> ICofiCollectionDispatcher { + let contract = declare("CofiCollection").unwrap().contract_class(); + + let mut calldata: Array = array![]; + calldata.append_serde(OWNER()); // default_admin + calldata.append_serde(OWNER()); // pauser + calldata.append_serde(OWNER()); // minter + calldata.append_serde(OWNER()); // uri_setter + calldata.append_serde(OWNER()); // upgrader + + let (contract_address, _) = contract.deploy(@calldata).unwrap(); + let cofi_collection = ICofiCollectionDispatcher { contract_address }; + + cofi_collection + } + + fn deploy_marketplace(cofi_collection: ContractAddress) -> IMarketplaceDispatcher { + let contract = declare("Marketplace").unwrap().contract_class(); + + let mut calldata: Array = array![]; + calldata.append_serde(cofi_collection); // coffi_collection + calldata.append_serde(OWNER()); // admin + calldata.append_serde(MARKET_FEE); // market fee + + let (contract_address, _) = contract.deploy(@calldata).unwrap(); + let marketplace = IMarketplaceDispatcher { contract_address }; + + marketplace + } + + fn calculate_total_token_price(unit_price: u256, amount: u256) -> u256 { + let total_price = unit_price * amount; + let market_fee = total_price * MARKET_FEE / 10000; + total_price + market_fee + } + + #[test] + fn test_assign_seller_role() { + let cofi_collection = deploy_cofi_collection(); + let marketplace = deploy_marketplace(cofi_collection.contract_address); + + start_cheat_caller_address(marketplace.contract_address, OWNER()); + marketplace.assign_seller_role(OWNER()); + } + + #[test] + fn test_assign_consumer_role() { + let cofi_collection = deploy_cofi_collection(); + let marketplace = deploy_marketplace(cofi_collection.contract_address); + + start_cheat_caller_address(marketplace.contract_address, OWNER()); + marketplace.assign_consumer_role(OWNER()); + } + + #[test] + fn test_assign_admin_role() { + let cofi_collection = deploy_cofi_collection(); + let marketplace = deploy_marketplace(cofi_collection.contract_address); + + start_cheat_caller_address(marketplace.contract_address, OWNER()); + let ANYONE = starknet::contract_address_const::<'ANYONE'>(); + marketplace.assign_admin_role(ANYONE); + } + + #[test] + fn test_create_product() { + let cofi_collection = deploy_cofi_collection(); + let marketplace = deploy_marketplace(cofi_collection.contract_address); + + // Create a producer + start_cheat_caller_address(marketplace.contract_address, OWNER()); + let PRODUCER = starknet::contract_address_const::<'PRODUCER'>(); + marketplace.assign_seller_role(PRODUCER); + + // Give marketplace permission to mint tokens + start_cheat_caller_address(cofi_collection.contract_address, OWNER()); + cofi_collection.set_minter(marketplace.contract_address); + + // Create a product + start_cheat_caller_address(marketplace.contract_address, PRODUCER); + let data = array!['testing'].span(); + let price = 1 * ONE_E18; // 1 stark + let token_id = marketplace.create_product(10, price, data); + assert(token_id == 1, 'invalid token id'); + } + + #[test] + fn test_create_products() { + let cofi_collection = deploy_cofi_collection(); + let marketplace = deploy_marketplace(cofi_collection.contract_address); + + // Create a producer + start_cheat_caller_address(marketplace.contract_address, OWNER()); + let PRODUCER = starknet::contract_address_const::<'PRODUCER'>(); + marketplace.assign_seller_role(PRODUCER); + + // Give marketplace permission to mint tokens + start_cheat_caller_address(cofi_collection.contract_address, OWNER()); + cofi_collection.set_minter(marketplace.contract_address); + + // Create a product + start_cheat_caller_address(marketplace.contract_address, PRODUCER); + let initial_stock = array![10, 9].span(); + let price = array![1 * ONE_E18, 2 * ONE_E18].span(); + let token_ids = marketplace.create_products(initial_stock, price); + assert(token_ids == array![1, 2].span(), 'invalid token ids'); + } + + #[test] + #[fork("SEPOLIA_LATEST")] + fn test_buy_product() { + let cofi_collection = deploy_cofi_collection(); + let CONSUMER = deploy_receiver(); + let marketplace = deploy_marketplace(cofi_collection.contract_address); + + // Create a producer + start_cheat_caller_address(marketplace.contract_address, OWNER()); + let PRODUCER = starknet::contract_address_const::<'PRODUCER'>(); + marketplace.assign_seller_role(PRODUCER); + + // Give marketplace permission to mint tokens + start_cheat_caller_address(cofi_collection.contract_address, OWNER()); + cofi_collection.set_minter(marketplace.contract_address); + + // Create a product + start_cheat_caller_address(marketplace.contract_address, PRODUCER); + let data = array!['testing'].span(); + let price = 1 * ONE_E18; // 1 stark + let token_id = marketplace.create_product(10, price, data); + + // Create a consumer + start_cheat_caller_address(marketplace.contract_address, OWNER()); + marketplace.assign_seller_role(CONSUMER); + + // Fund buyer wallet + let amount_to_buy = 2; + let required_tokens = calculate_total_token_price(price, amount_to_buy); + let minter_address = starknet::contract_address_const::(); + let token_address = starknet::contract_address_const::(); + let token_dispatcher = IERC20Dispatcher { contract_address: token_address }; + + start_cheat_caller_address(token_address, minter_address); + let mut calldata = array![]; + calldata.append_serde(CONSUMER); + calldata.append_serde(required_tokens); + call_contract_syscall(token_address, selector!("permissioned_mint"), calldata.span()) + .unwrap(); + + assert(token_dispatcher.balance_of(CONSUMER) == required_tokens, 'invalid balance'); + + // Approve marketplace to spend buyer's tokens + start_cheat_caller_address(token_address, CONSUMER); + token_dispatcher.approve(marketplace.contract_address, required_tokens); + + // Buy a product + start_cheat_caller_address(marketplace.contract_address, CONSUMER); + start_cheat_caller_address(token_address, marketplace.contract_address); + start_cheat_caller_address(cofi_collection.contract_address, marketplace.contract_address); + marketplace.buy_product(token_id, amount_to_buy); + + let minted_nfts = cofi_collection.balance_of(CONSUMER, token_id); + assert(minted_nfts == amount_to_buy, 'invalid minted nfts'); + } + + #[test] + #[fork("SEPOLIA_LATEST")] + fn test_buy_products() { + let cofi_collection = deploy_cofi_collection(); + let CONSUMER = deploy_receiver(); + let marketplace = deploy_marketplace(cofi_collection.contract_address); + + // Create a producer + start_cheat_caller_address(marketplace.contract_address, OWNER()); + let PRODUCER = starknet::contract_address_const::<'PRODUCER'>(); + marketplace.assign_seller_role(PRODUCER); + + // Give marketplace permission to mint tokens + start_cheat_caller_address(cofi_collection.contract_address, OWNER()); + cofi_collection.set_minter(marketplace.contract_address); + + // Create a product + start_cheat_caller_address(marketplace.contract_address, PRODUCER); + let initial_stock = array![10, 9].span(); + let price = array![100001, 100002].span(); + let token_ids = marketplace.create_products(initial_stock, price); + + // Create a consumer + start_cheat_caller_address(marketplace.contract_address, OWNER()); + marketplace.assign_seller_role(CONSUMER); + + // Fund buyer wallet + let minter_address = starknet::contract_address_const::(); + let token_address = starknet::contract_address_const::(); + let token_dispatcher = IERC20Dispatcher { contract_address: token_address }; + + start_cheat_caller_address(token_address, minter_address); + let mut calldata = array![]; + calldata.append_serde(CONSUMER); + calldata.append_serde(50000000_u256); + call_contract_syscall(token_address, selector!("permissioned_mint"), calldata.span()) + .unwrap(); + + assert(token_dispatcher.balance_of(CONSUMER) == 50000000_u256, 'invalid balance'); + + // Approve marketplace to spend buyer's tokens + start_cheat_caller_address(token_address, CONSUMER); + token_dispatcher.approve(marketplace.contract_address, 50000000_u256); + + // Buy a product + start_cheat_caller_address(marketplace.contract_address, CONSUMER); + start_cheat_caller_address(token_address, marketplace.contract_address); + start_cheat_caller_address(cofi_collection.contract_address, marketplace.contract_address); + let token_amounts = array![2, 3].span(); + marketplace.buy_products(token_ids, token_amounts); + } + + #[test] + fn test_delete_product() { + let cofi_collection = deploy_cofi_collection(); + let marketplace = deploy_marketplace(cofi_collection.contract_address); + + // Create a producer + start_cheat_caller_address(marketplace.contract_address, OWNER()); + let PRODUCER = starknet::contract_address_const::<'PRODUCER'>(); + marketplace.assign_seller_role(PRODUCER); + + // Give marketplace permission to mint tokens + start_cheat_caller_address(cofi_collection.contract_address, OWNER()); + cofi_collection.set_minter(marketplace.contract_address); + + // Create a product + start_cheat_caller_address(marketplace.contract_address, PRODUCER); + let data = array!['testing'].span(); + let price = 1 * ONE_E18; // 1 stark + let token_id = marketplace.create_product(10, price, data); + assert(token_id == 1, 'invalid token id'); + + // Delete the product + start_cheat_caller_address(cofi_collection.contract_address, marketplace.contract_address); + let tokens_before = cofi_collection.balance_of(marketplace.contract_address, token_id); + assert(tokens_before == 10, 'invalid tokens before'); + + marketplace.delete_product(token_id); + let tokens_after = cofi_collection.balance_of(marketplace.contract_address, token_id); + assert(tokens_after == 0, 'invalid tokens after'); + } + + #[test] + fn test_delete_products() { + let cofi_collection = deploy_cofi_collection(); + let marketplace = deploy_marketplace(cofi_collection.contract_address); + + // Create a producer + start_cheat_caller_address(marketplace.contract_address, OWNER()); + let PRODUCER = starknet::contract_address_const::<'PRODUCER'>(); + marketplace.assign_seller_role(PRODUCER); + + // Give marketplace permission to mint tokens + start_cheat_caller_address(cofi_collection.contract_address, OWNER()); + cofi_collection.set_minter(marketplace.contract_address); + + // Create products + start_cheat_caller_address(marketplace.contract_address, PRODUCER); + let initial_stock = array![10, 9].span(); + let price = array![1 * ONE_E18, 2 * ONE_E18].span(); + let token_ids = marketplace.create_products(initial_stock, price); + + // Delete the product + start_cheat_caller_address(cofi_collection.contract_address, marketplace.contract_address); + let tokens_before = cofi_collection + .balance_of(marketplace.contract_address, *token_ids.at(0)); + assert(tokens_before == 10, 'invalid tokens before'); + + marketplace.delete_products(token_ids); + let tokens_after = cofi_collection + .balance_of(marketplace.contract_address, *token_ids.at(0)); + assert(tokens_after == 0, 'invalid tokens after'); + } + + #[test] + #[fork("SEPOLIA_LATEST")] + fn test_claim() { + let cofi_collection = deploy_cofi_collection(); + let CONSUMER = deploy_receiver(); + let PRODUCER = deploy_receiver(); + let marketplace = deploy_marketplace(cofi_collection.contract_address); + + // Create a producer + start_cheat_caller_address(marketplace.contract_address, OWNER()); + marketplace.assign_seller_role(PRODUCER); + + // Give marketplace permission to mint tokens + start_cheat_caller_address(cofi_collection.contract_address, OWNER()); + cofi_collection.set_minter(marketplace.contract_address); + + // Create a product + start_cheat_caller_address(marketplace.contract_address, PRODUCER); + let data = array!['testing'].span(); + let price = 1 * ONE_E18; // 1 stark + let token_id = marketplace.create_product(10, price, data); + + // Create a consumer + start_cheat_caller_address(marketplace.contract_address, OWNER()); + marketplace.assign_seller_role(CONSUMER); + + // Fund buyer wallet + let amount_to_buy = 2; + let required_tokens = calculate_total_token_price(price, amount_to_buy); + let minter_address = starknet::contract_address_const::(); + let token_address = starknet::contract_address_const::(); + let token_dispatcher = IERC20Dispatcher { contract_address: token_address }; + + start_cheat_caller_address(token_address, minter_address); + let mut calldata = array![]; + calldata.append_serde(CONSUMER); + calldata.append_serde(required_tokens); + call_contract_syscall(token_address, selector!("permissioned_mint"), calldata.span()) + .unwrap(); + + // Approve marketplace to spend buyer's tokens + start_cheat_caller_address(token_address, CONSUMER); + token_dispatcher.approve(marketplace.contract_address, required_tokens); + + // Buy a product + start_cheat_caller_address(marketplace.contract_address, CONSUMER); + start_cheat_caller_address(token_address, marketplace.contract_address); + start_cheat_caller_address(cofi_collection.contract_address, marketplace.contract_address); + marketplace.buy_product(token_id, amount_to_buy); + + let minted_nfts = cofi_collection.balance_of(CONSUMER, token_id); + assert(minted_nfts == amount_to_buy, 'invalid minted nfts'); + + // Claim the rewards + start_cheat_caller_address(token_address, marketplace.contract_address); + start_cheat_caller_address(marketplace.contract_address, PRODUCER); + let producer_starks_before = token_dispatcher.balance_of(PRODUCER); + + let claim_balance = marketplace.claim_balance(PRODUCER); + assert(claim_balance > 0, 'invalid claim balance'); + + marketplace.claim(); + let producer_starks_after = token_dispatcher.balance_of(PRODUCER); + assert(producer_starks_before < producer_starks_after, 'invalid producer starks'); + } +} + diff --git a/apps/snfoundry/deployments/.gitignore b/apps/snfoundry/deployments/.gitignore deleted file mode 100644 index c96a04f..0000000 --- a/apps/snfoundry/deployments/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore \ No newline at end of file diff --git a/apps/snfoundry/deployments/sepolia_latest.json b/apps/snfoundry/deployments/sepolia_latest.json new file mode 100644 index 0000000..5caba83 --- /dev/null +++ b/apps/snfoundry/deployments/sepolia_latest.json @@ -0,0 +1,12 @@ +{ + "CofiCollection": { + "classHash": "0x7a108607024be2054d1558cbfd5de3fe264afe9e025bc7653b1bc5fc76d1af0", + "address": "0x375a9b9236e5c02e95de236811259ff7877832ad65f752cb1fabd4d2127f930", + "contract": "cofi_collection.cairo" + }, + "Marketplace": { + "classHash": "0x44eb6074fa74a9dbddb65c52394cd1bab274e3bc22e1fc36c26c37686d1e440", + "address": "0x3fc6df11c8e7eb7adf78ca9febce62cafddd0caef1214a0843839609539a180", + "contract": "marketplace.cairo" + } +} diff --git a/apps/snfoundry/package.json b/apps/snfoundry/package.json index 4d34445..f512eee 100644 --- a/apps/snfoundry/package.json +++ b/apps/snfoundry/package.json @@ -3,7 +3,7 @@ "version": "0.0.1", "scripts": { "chain": "starknet-devnet --seed 0 --account-class cairo1", - "deploy": "ts-node scripts-ts/helpers/deploy-wrapper.ts", + "deploy:sepolia": "ts-node scripts-ts/helpers/deploy-wrapper.ts --network sepolia", "deploy:no-reset": "snfoundry deploy --no-reset", "test": "cd contracts && snforge test", "test-eslint": "node eslint-contract-name/eslint-plugin-contract-names.test.js", diff --git a/apps/snfoundry/scripts-cairo/README.md b/apps/snfoundry/scripts-cairo/README.md deleted file mode 100644 index 62270fa..0000000 --- a/apps/snfoundry/scripts-cairo/README.md +++ /dev/null @@ -1 +0,0 @@ -# COMING SOON diff --git a/apps/snfoundry/scripts-ts/deploy.ts b/apps/snfoundry/scripts-ts/deploy.ts index 96ea2b9..b463f1c 100644 --- a/apps/snfoundry/scripts-ts/deploy.ts +++ b/apps/snfoundry/scripts-ts/deploy.ts @@ -1,10 +1,11 @@ +import { addAddressPadding, byteArray } from "starknet"; import { deployContract, deployer, executeDeployCalls, exportDeployments, } from "./deploy-contract"; -import { green } from "./helpers/colorize-log"; +import { green, yellow } from "./helpers/colorize-log"; /** * Deploy a contract using the specified parameters. @@ -41,10 +42,24 @@ import { green } from "./helpers/colorize-log"; * * @returns {Promise} */ + +const string_to_byte_array = (str: string): string[] => { + const byte_array = byteArray.byteArrayFromString(str); + const result = [`0x${byte_array.data.length.toString(16)}`]; + for (let i = 0; i < byte_array.data.length; i++) { + result.push(byte_array.data[i].toString()); + } + if (byte_array.pending_word) { + result.push(byte_array.pending_word.toString()); + } + result.push(`0x${byte_array.pending_word_len.toString(16)}`); + return result; +}; + const deployScript = async (): Promise => { - console.log("πŸš€ Deploying with address:", green(deployer.address)); + console.log("πŸš€ Creating deployment calls..."); - await deployContract({ + const { address: cofiCollectionAddress } = await deployContract({ contract: "cofi_collection.cairo", contractName: "CofiCollection", constructorArgs: { @@ -55,11 +70,52 @@ const deployScript = async (): Promise => { upgrader: deployer.address, }, }); + + const { address: marketplaceAddress } = await deployContract({ + contract: "marketplace.cairo", + contractName: "Marketplace", + constructorArgs: { + cofi_collection_address: cofiCollectionAddress, + admin: deployer.address, + market_fee: BigInt(250), // 2.5 % + }, + }); + + console.log( + "CofiCollection will be deployed at:", + green(cofiCollectionAddress), + ); + console.log("Marketplace will be deployed at:", green(marketplaceAddress)); + await executeDeployCalls(); + + console.log("πŸš€ Setting marketplace as minter and setting base uri..."); + const base_uri_txt = process.env.TOKEN_METADATA_URL || ""; + console.log("Base URI:", base_uri_txt); + const transactions = [ + { + contractAddress: cofiCollectionAddress, + entrypoint: "set_minter", + calldata: { + minter: marketplaceAddress, + }, + }, + { + contractAddress: cofiCollectionAddress, + entrypoint: "set_base_uri", + calldata: string_to_byte_array(base_uri_txt), + }, + ]; + const { transaction_hash } = await deployer.execute(transactions); + console.log("πŸš€ Final transactions hash", transaction_hash); + console.log( + yellow( + "Make sure to update the contracts metadata in web app!! See README.md", + ), + ); }; deployScript() .then(async () => { - await executeDeployCalls(); exportDeployments(); console.log(green("All Setup Done")); diff --git a/apps/snfoundry/scripts-ts/helpers/deploy-wrapper.ts b/apps/snfoundry/scripts-ts/helpers/deploy-wrapper.ts index 61addc0..4d96c32 100644 --- a/apps/snfoundry/scripts-ts/helpers/deploy-wrapper.ts +++ b/apps/snfoundry/scripts-ts/helpers/deploy-wrapper.ts @@ -10,29 +10,40 @@ interface CommandLineOptions { fee?: string; } -const argv = yargs(process.argv.slice(2)) - .options({ - network: { type: "string" }, - fee: { type: "string", choices: ["eth", "strk"], default: "eth" }, - reset: { +function main() { + const argv = yargs(process.argv.slice(2)) + .option("network", { + type: "string", + choices: ["devnet", "sepolia", "mainnet"], + default: "sepolia", + }) + .option("fee", { type: "string", choices: ["eth", "strk"], default: "eth" }) + .option("reset", { type: "boolean", description: "Do not reset deployments (keep existing deployments)", default: true, - }, - }) - .parseSync() as CommandLineOptions; + }) + .demandOption(["network", "fee", "reset"]) + .parseSync() as CommandLineOptions; -// Set the NETWORK environment variable based on the --network argument -process.env.NETWORK = argv.network || "devnet"; -process.env.FEE_TOKEN = argv.fee || "eth"; -process.env.NO_RESET = !argv.reset ? "true" : "false"; + if (argv._.length > 0) { + console.error( + "❌ Invalid arguments, only --network, --fee, or --reset/--no-reset can be passed in", + ); + return; + } -// Execute the deploy script without the reset option -try { - execSync( - `cd apps/snfoundry/contracts && scarb build && ts-node ../scripts-ts/deploy.ts --network ${process.env.NETWORK} --fee ${process.env.FEE_TOKEN} --no-reset ${process.env.NO_RESET} && ts-node ../scripts-ts/helpers/parse-deployments.ts && cd ../../..`, - { stdio: "inherit" }, - ); -} catch (error) { - console.error("Error during deployment:", error); + // Execute the deploy script without the reset option + try { + execSync( + `cd contracts && scarb build && ts-node ../scripts-ts/deploy.ts --network ${argv.network || "devnet"} --fee ${argv.fee || "eth"} ${!argv.reset && "--no-reset "} && ts-node ../scripts-ts/helpers/parse-deployments.ts && cd ..`, + { stdio: "inherit" }, + ); + } catch (error) { + console.error("Error during deployment:", error); + } +} + +if (require.main === module) { + main(); } diff --git a/apps/snfoundry/scripts-ts/helpers/error-handler.ts b/apps/snfoundry/scripts-ts/helpers/error-handler.ts new file mode 100644 index 0000000..e4b20cb --- /dev/null +++ b/apps/snfoundry/scripts-ts/helpers/error-handler.ts @@ -0,0 +1,108 @@ +import { Provider } from "starknet"; +import { DeploymentError, DeploymentErrorType, RetryConfig, Networks } from "../types"; + +class Logger { + private formatMessage(level: string, message: any): string { + const timestamp = new Date().toISOString(); + return `[${timestamp}] ${level}: ${typeof message === 'string' ? message : JSON.stringify(message)}`; + } + + info(message: any): void { + console.log(this.formatMessage('INFO', message)); + } + + warn(message: any): void { + console.warn(this.formatMessage('WARN', message)); + } + + error(message: any): void { + console.error(this.formatMessage('ERROR', message)); + } +} + +export const logger = new Logger(); + +const defaultRetryConfig: RetryConfig = { + maxAttempts: 3, + initialDelay: 1000, + maxDelay: 10000, + factor: 2 +}; + +export async function withRetry( + operation: () => Promise, + config: RetryConfig = defaultRetryConfig, + context: string +): Promise { + let delay = config.initialDelay; + let attempt = 0; + + while (attempt < config.maxAttempts) { + try { + return await operation(); + } catch (error: any) { + attempt++; + + const errorType = classifyStarknetError(error); + logger.warn({ + message: `Retry attempt ${attempt}/${config.maxAttempts} for ${context}`, + error: error.message, + type: errorType + }); + + if (attempt === config.maxAttempts || !isRetryableError(errorType)) { + throw new DeploymentError(errorType, error.message); + } + + await sleep(delay); + delay = Math.min(delay * config.factor, config.maxDelay); + } + } + + throw new DeploymentError( + DeploymentErrorType.UNKNOWN_ERROR, + `Max retry attempts (${config.maxAttempts}) reached for ${context}` + ); +} + +function classifyStarknetError(error: any): DeploymentErrorType { + const errorMsg = error.message.toLowerCase(); + + if (errorMsg.includes("insufficient max fee")) { + return DeploymentErrorType.GAS_ERROR; + } + if (errorMsg.includes("invalid transaction nonce")) { + return DeploymentErrorType.NONCE_ERROR; + } + if (errorMsg.includes("network") || errorMsg.includes("timeout")) { + return DeploymentErrorType.NETWORK_ERROR; + } + if (errorMsg.includes("contract") || errorMsg.includes("class hash")) { + return DeploymentErrorType.CONTRACT_ERROR; + } + if (errorMsg.includes("invalid") || errorMsg.includes("validation")) { + return DeploymentErrorType.VALIDATION_ERROR; + } + return DeploymentErrorType.UNKNOWN_ERROR; +} + +function isRetryableError(errorType: DeploymentErrorType): boolean { + return [ + DeploymentErrorType.NETWORK_ERROR, + DeploymentErrorType.GAS_ERROR, + DeploymentErrorType.NONCE_ERROR + ].includes(errorType); +} + +export async function validateNetwork(provider: Provider): Promise { + try { + await provider.getChainId(); + } catch (error) { + throw new DeploymentError( + DeploymentErrorType.NETWORK_ERROR, + "Failed to validate network connection" + ); + } +} + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); \ No newline at end of file diff --git a/apps/snfoundry/scripts-ts/types.ts b/apps/snfoundry/scripts-ts/types.ts index 0d43f9c..f77e080 100644 --- a/apps/snfoundry/scripts-ts/types.ts +++ b/apps/snfoundry/scripts-ts/types.ts @@ -14,3 +14,39 @@ export type DeployContractParams = { constructorArgs?: RawArgs; options?: UniversalDetails; }; + +export enum DeploymentErrorType { + VALIDATION_ERROR = "VALIDATION_ERROR", + NETWORK_ERROR = "NETWORK_ERROR", + GAS_ERROR = "GAS_ERROR", + NONCE_ERROR = "NONCE_ERROR", + CONTRACT_ERROR = "CONTRACT_ERROR", + UNKNOWN_ERROR = "UNKNOWN_ERROR", +} + +export class DeploymentError extends Error { + constructor( + public type: DeploymentErrorType, + message: string, + public txHash?: string, + public contractAddress?: string + ) { + super(message); + this.name = "DeploymentError"; + } +} + +export interface RetryConfig { + maxAttempts: number; + initialDelay: number; + maxDelay: number; + factor: number; +} + +export interface TransactionQueueItem { + id: string; + execute: () => Promise; + priority: number; + network: keyof Networks; + dependencies?: string[]; +} diff --git a/apps/web/.env.example b/apps/web/.env.example deleted file mode 100644 index 5435ba1..0000000 --- a/apps/web/.env.example +++ /dev/null @@ -1,8 +0,0 @@ -# DATABASE_URL='mysql://root:RbofLLysJtcYrazMMsSzpuTAYxMkaVsw@roundhouse.proxy.rlwy.net:21057/railway' -DATABASE_URL="" -NEXTAUTH_URL="http://localhost:3000" -NODE_ENV="development" -NEXT_PUBLIC_STARKNETKIT_PROJECT_ID=abc123 -NEXTAUTH_SECRET=secret -NEXT_PUBLIC_BASE_URL=/ -NEXT_PUBLIC_SIGNER_ADDRESS=0x123 \ No newline at end of file diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 7c19e7b..1912e20 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -46,3 +46,7 @@ yarn-error.log* .idea *storybook.log storybook-static + +# prompts +instructions/ +instructions/* diff --git a/apps/web/.storybook/main.ts b/apps/web/.storybook/main.ts index ed1d7e9..a84f78e 100644 --- a/apps/web/.storybook/main.ts +++ b/apps/web/.storybook/main.ts @@ -1,14 +1,5 @@ import type { StorybookConfig } from "@storybook/nextjs"; -import { dirname, join } from "path"; - -/** - * This function is used to resolve the absolute path of a package. - * It is needed in projects that use Yarn PnP or are set up within a monorepo. - */ -function getAbsolutePath(value: string): string { - return dirname(require.resolve(join(value, "package.json"))); -} const config: StorybookConfig = { stories: [ "../src/**/*.mdx", @@ -16,16 +7,13 @@ const config: StorybookConfig = { "../../../packages/ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)", ], addons: [ - getAbsolutePath("@storybook/addon-onboarding"), - getAbsolutePath("@storybook/addon-links"), - getAbsolutePath("@storybook/addon-essentials"), - getAbsolutePath("@chromatic-com/storybook"), - getAbsolutePath("@storybook/addon-interactions"), - getAbsolutePath("@storybook/addon-styling-webpack"), - getAbsolutePath("@storybook/addon-themes"), + "@storybook/addon-onboarding", + "@storybook/addon-essentials", + "@chromatic-com/storybook", + "@storybook/addon-interactions", ], framework: { - name: getAbsolutePath("@storybook/nextjs"), + name: "@storybook/nextjs", options: {}, }, staticDirs: ["../public"], diff --git a/apps/web/README.md b/apps/web/README.md index 67943c7..e60cae6 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -1,29 +1,54 @@ -# Create T3 App +# CofiBlocks Web -This is a [T3 Stack](https://create.t3.gg/) project bootstrapped with `create-t3-app`. +## Running the Application -## What's next? How do I make an app with this? +Follow the steps below to set up and run the CofiBlocks web application. -We try to keep this project as simple as possible, so you can start with just the scaffolding we set up for you, and add additional things later when they become necessary. +### 1. Set Up Environment Variables -If you are not familiar with the different technologies used in this project, please refer to the respective docs. If you still are in the wind, please join our [Discord](https://t3.gg/discord) and ask for help. +Create a `.env` file by copying the example configuration: -- [Next.js](https://nextjs.org) -- [NextAuth.js](https://next-auth.js.org) -- [Prisma](https://prisma.io) -- [Drizzle](https://orm.drizzle.team) -- [Tailwind CSS](https://tailwindcss.com) -- [tRPC](https://trpc.io) +```bash +cp .env.example .env +``` -## Learn More +Then, fill in the required values in the `.env` file: -To learn more about the [T3 Stack](https://create.t3.gg/), take a look at the following resources: +- **`DATABASE_URL`**: The database connection URL. + - Example for a local MySQL setup: + ``` + mysql://root:root@127.0.0.1:3306/web + ``` -- [Documentation](https://create.t3.gg/) -- [Learn the T3 Stack](https://create.t3.gg/en/faq#what-learning-resources-are-currently-available) β€” Check out these awesome tutorials +### 2. Start the Database -You can check out the [create-t3-app GitHub repository](https://github.com/t3-oss/create-t3-app) β€” your feedback and contributions are welcome! +Ensure Docker is running and execute the script to start the database: -## How do I deploy this? +```bash +./start-database.sh +``` + +### 3. Generate Database Migrations + +Run the following command to generate the required database migrations: + +```bash +bun db:generate +``` + +### 4. Generate Prisma Client + +Execute the command to generate the Prisma client: + +```bash +bun postinstall +``` + +### 5. Start the Application + +To run the application in development mode, use: + +```bash +bun turbo dev +``` -Follow our deployment guides for [Vercel](https://create.t3.gg/en/deployment/vercel), [Netlify](https://create.t3.gg/en/deployment/netlify) and [Docker](https://create.t3.gg/en/deployment/docker) for more information. diff --git a/apps/web/next-i18next.config.js b/apps/web/next-i18next.config.js index 4e0c7ac..f616366 100644 --- a/apps/web/next-i18next.config.js +++ b/apps/web/next-i18next.config.js @@ -2,6 +2,6 @@ export default { i18n: { defaultLocale: "en", locales: ["en", "es", "pt"], - localeDetection: false, + localeDetection: true, }, }; diff --git a/apps/web/package.json b/apps/web/package.json index 712966c..74ccc86 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "prisma": { - "schema": "apps/web/prisma/schema.prisma", - "seed": "node apps/web/prisma/seed.js" + "schema": "./prisma/schema.prisma", + "seed": "./prisma/seed.js" }, "scripts": { "build": "next build", @@ -22,7 +22,7 @@ }, "dependencies": { "@auth/prisma-adapter": "^1.6.0", - "@prisma/client": "^5.14.0", + "@prisma/client": "^6.2.1", "@repo/ui": "*", "@starknet-react/chains": "^0.1.7", "@starknet-react/core": "^2.9.0", @@ -37,8 +37,10 @@ "i18next": "^23.15.1", "next": "^14.2.4", "next-auth": "^4.24.7", + "pinata-web3": "^0.5.3", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-hot-toast": "^2.5.1", "react-i18next": "^15.0.2", "server-only": "^0.0.1", "starknet": "^6.11.0", diff --git a/apps/web/prisma/migrations/20250120164024_add_token_id_to_product/migration.sql b/apps/web/prisma/migrations/20250120164024_add_token_id_to_product/migration.sql new file mode 100644 index 0000000..6a0b726 --- /dev/null +++ b/apps/web/prisma/migrations/20250120164024_add_token_id_to_product/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Added the required column `tokenId` to the `Product` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE `Product` ADD COLUMN `tokenId` INTEGER NOT NULL; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 2a31d2d..ba8efb7 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -35,6 +35,7 @@ model ShoppingCart { model Product { id Int @id @default(autoincrement()) + tokenId Int name String price Float nftMetadata Json diff --git a/apps/web/prisma/seed.js b/apps/web/prisma/seed.js index aab73d3..c7ce46b 100644 --- a/apps/web/prisma/seed.js +++ b/apps/web/prisma/seed.js @@ -107,6 +107,7 @@ async function main() { prisma.product.create({ data: { name: card.title, + tokenId: index + 1, price: 15.99 + index * 2, // Randomize price for realism nftMetadata: JSON.stringify({ description: card.description, diff --git a/apps/web/public/locales/en/common.json b/apps/web/public/locales/en/common.json index a007329..a43b0d3 100644 --- a/apps/web/public/locales/en/common.json +++ b/apps/web/public/locales/en/common.json @@ -2,11 +2,282 @@ "welcome_to": "Welcome to", "sign": "Sign", "disconnect": "Disconnect", + "connect_wallet": "Connect {{walletName}}", + "argent_x": "Argent X", + "error_signing_message": "Error signing the message", "tag_new": "New", "welcome_coffee_lover": "Welcome Coffee Lover", "featured": "Featured", "find_best_coffee": "Find Best Coffee", "popular": "Popular", - "discover_unique_blends": "Discover Unique Blends" + "discover_unique_blends": "Discover Unique Blends", + + "producing_since": "producing coffee since {{since}}", + "bio_title": "Bio", + "experiences_title": "Experiences", + "good_practices_title": "Good practices", + "edit_button": "Edit", + + "settings": "Settings", + "language": "Language", + "apply": "Apply", + + "filter_by": "Filter by", + "clear": "Clear", + "roast_level": "Roast level", + "region": "Region", + "order_by": "Order by", + "strength": { + "light": "Light", + "medium": "Medium", + "strong": "Strong", + "extra_strong": "Extra Strong", + "mild": "Mild" + }, + "processes": { + "honey": "Honey", + "washed": "Washed", + "natural": "Natural" + }, + "regions": { + "region_a": "Region A", + "region_b": "Region B", + "region_c": "Region C" + }, + "order": { + "highest_price": "Highest price", + "lowest_price": "Lowest price" + }, + + "disconnect_confirmation": "Do you want to disconnect?", + "yes_disconnect": "Yes, disconnect", + "cancel": "Cancel", + "logout_logic": "Please implement logout functionality.", + + "product_image_alt": "Image of {{productName}}", + "order_status": { + "delivered": "Delivered", + "pending": "Pending", + "canceled": "Canceled", + "paid": "Paid", + "prepared": "Prepared", + "shipped": "Shipped" + }, + "ordered_by": "Ordered by {{name}}", + "price_with_currency": "{{price}} USD", + + "my_collectibles": "My collectibles", + "collectible_title_1": "Specialty Coffee 1", + "collectible_description_1": "Description of Specialty Coffee 1.", + "collectible_image_alt_1": "Package of Specialty Coffee 1", + "collectible_title_2": "Specialty Coffee 2", + "collectible_description_2": "Description of Specialty Coffee 2.", + "collectible_image_alt_2": "Package of Specialty Coffee 2", + "collectible_title_3": "Specialty Coffee 3", + "collectible_description_3": "Description of Specialty Coffee 3.", + "collectible_image_alt_3": "Package of Specialty Coffee 3", + + "product_name_1": "Specialty Coffee 1", + "product_description_1": "Description of Specialty Coffee 1.", + "product_image_alt_1": "Package of Specialty Coffee 1", + "product_name_2": "Specialty Coffee 2", + "product_description_2": "Description of Specialty Coffee 2.", + "product_image_alt_2": "Package of Specialty Coffee 2", + "product_name_3": "Specialty Coffee 3", + "product_description_3": "Description of Specialty Coffee 3.", + "product_image_alt_3": "Package of Specialty Coffee 3", + "product_name_4": "Specialty Coffee 4", + "product_description_4": "Description of Specialty Coffee 4.", + "product_image_alt_4": "Package of Specialty Coffee 4", + "product_name_5": "Specialty Coffee 5", + "product_description_5": "Description of Specialty Coffee 5.", + "product_image_alt_5": "Package of Specialty Coffee 5", + "product_name_6": "Specialty Coffee 6", + "product_description_6": "Description of Specialty Coffee 6.", + "product_image_alt_6": "Package of Specialty Coffee 6", + "product_name_7": "Specialty Coffee 7", + "product_description_7": "Description of Specialty Coffee 7.", + "product_image_alt_7": "Package of Specialty Coffee 7", + + "products_found": "{{count}} products found", + "clear_search": "Clear search", + "no_results_image_alt": "No results", + + "farm": "Farm", + "region_by_farm": "{{region}} by {{farmName}}", + "per_unit": "/unit", + + "alert": { + "success": "Operation completed successfully!", + "error": "An error occurred. Please try again.", + "info": "Here is some important information.", + "dismiss_button": "Dismiss" + }, + + "go_to_slide": "Go to slide {{index}}", + + "chat_with_seller": "Chat with the seller", + "bags_available": "Bags Available", + "bags": "bags", + "unit_price": "Unit price ({{weight}})", + "sold_out": "SOLD OUT", + "add_to_favorites": "Add to favorites", + "remove_from_favorites": "Remove from favorites", + "edit_my_farm": "Edit my farm", + "process": "Process", + + "about_the_producer": "About the producer", + "farm_bio_placeholder": "Farm bio description", + "farm_experiences_placeholder": "Farm experiences", + "farm_good_practices_placeholder": "Farm good practices", + "producer_avatar_alt": "Producer Avatar", + "farm_icon_alt": "Farm icon", + "reviews": "Reviews", + "reviews_icon_alt": "Reviews icon", + "star_icon_alt": "Star {{starIndex}}", + "sales_on_cofiblocks": "Sales on Cofiblocks", + "region_icon_alt": "Region icon", + "altitude_icon_alt": "Altitude icon", + "coordinates_icon_alt": "Coordinates icon", + "website_icon_alt": "Website icon", + "altitude": "Altitude", + "altitude_value": "{{altitude}} meters", + "coordinates": "Coordinates", + "website": "Website", + "sales_icon_alt": "Sales icon", + + "select_coffee_type": "Select Coffee Type", + "coffee_type": { + "bean": "Bean", + "grounded": "Grounded" + }, + "total": "total", + "adding_to_cart": "Adding to cart...", + "add_to_cart": "Add to cart", + "shopping_cart_title": "My cart", + "cart_empty_message": "Your cart is empty", + "quantity_label": "quantity", + "total_label": "TOTAL", + "buy_button": "Buy", + "remove_confirmation_title": "Do you want to remove?", + "remove_confirmation_yes": "Yes, remove", + "aria_label_go_back": "Go back", + "aria_label_remove_item": "Remove {{itemName}} from cart", + + "search_placeholder": "Search for your coffee", + "open_filters": "Open filters", + "natural_process": "Natural", + + "edit_my_farm_profile": "Edit my farm profile", + "loading": "Loading...", + "farm_profile_image": "Farm Profile", + "no_image": "No Image", + "choose_photo": "Choose photo", + "farm_name": "Farm Name", + "altitude_meters": "Altitude (meters)", + "save_changes": "Save Changes", + "farm_name_required": "Farm name is required", + "region_required": "Region is required", + "altitude_positive": "Altitude must be a positive number", + "coordinates_invalid": "Invalid coordinates format", + "url_invalid": "Invalid URL", + "farm_profile_updated": "Farm profile updated", + "error_updating_farm": "An error occurred while updating the farm profile", + "user_farm_not_found": "User farm not found. Please contact support.", + "implement_image_upload": "Implement image upload logic", + + "New": "New", + "welcome Coffee Lover": "Welcome Coffee Lover", + "Featured": "Featured", + "Find Best Coffee": "Encontre o Melhor CafΓ©", + "Popular": "Popular", + "Discover Unique Blends": "Discover Unique Blends", + "Paid": "Paid", + "Delivered": "Delivered", + "delivered": "Delivered", + + "user_profile": "User Profile", + "lover": "Lover", + "contributor": "Contributor", + "producer": "Producer", + "united_states": "United States", + "since": "Since", + + "edit_my_profile": "Edit my profile", + "profile_image": "Profile", + "full_name": "Full Name", + "email": "Email", + "physical_address": "Physical Address", + "full_name_required": "Full name is required", + "invalid_email": "Invalid email", + "address_required": "Address is required", + "profile_updated": "Profile updated", + "invalidate_user_data_failed": "Failed to invalidate user data", + + "edit_profile": "Edit profile", + "my_coffee": "My Coffee", + "my_sales": "My Sales", + "my_claims": "My Claims", + "my_orders": "My Orders", + "favorite_products": "Favorite products", + "wallet": "Wallet", + "wallets": "Wallets", + "log_out": "Log out", + + "variety": "Variety", + "badge_text": "Badge Text", + + "search_seller_placeholder": "Search for seller's name", + "date": "Date: {{date}}", + "status": "Status", + "sample_product": "Sample Product", + "strong": "Strong", + "grounded": "Grounded", + "delivery": "Delivery", + + "october_18": "October 18", + "september_20": "September 20", + "product_name": "Sample Product", + + "buyer1_fullname": "Buyer 1", + "buyer2_fullname": "Buyer 2", + "seller1_fullname": "Seller 1", + "seller2_fullname": "Seller 2", + "delivery_method_label": "Delivery method", + "delivery_method": { + "address": "Address", + "meetup": "Meetup" + }, + "quantity_with_unit": "{{count}} {{unit}}", + "total_balance_receivable": "Total Balance Receivable", + "recieve": "Receive", + "history": "History", + "my_sellers": "My Sellers", + "offer_coffee": "Offer my coffee", + "sell_your_coffee": "Would you like to sell your coffee?", + "register_coffee": "Register my Coffee", + "choose_coffee_photo": "Choose coffee photo", + "coffee_variety": "Coffee variety", + "type_here": "Type here", + "coffee_description": "Coffee description", + "coffee_score": "Coffee Score", + "not_mandatory": "not mandatory", + "score_placeholder": "Enter a score between 0 and 100", + "operating_fee": "Operating fee per bag", + "price_per_bag": "Price per bag", + "enter_price_placeholder": "Enter price (e.g. 25.99)", + "producer_value_per_bag": "Value allocated to the producer per bag:", + "total_sales_value_per_bag": "Total sales value (product + fee) per bag:", + "save_and_publish": "Save and publish", + "profile_option_image_alt": "Image for {{label}}", + "details": "Details", + "shopping_cart": "Shopping Cart", + "remove": "Remove", + + "language_name": { + "en": "English", + "es": "Spanish", + "pt": "Portuguese" + } } diff --git a/apps/web/public/locales/es/common.json b/apps/web/public/locales/es/common.json index a056a8e..44de9ab 100644 --- a/apps/web/public/locales/es/common.json +++ b/apps/web/public/locales/es/common.json @@ -2,11 +2,282 @@ "welcome_to": "Bienvenido a", "sign": "Firmar", "disconnect": "Desconectar", + "connect_wallet": "Conectar {{walletName}}", + "argent_x": "Argent X", + "error_signing_message": "Error al firmar el mensaje", - "welcome_coffee_lover": "Bienvenido amante del cafΓ©", "tag_new": "Nuevo", + "welcome_coffee_lover": "Bienvenido Amante del CafΓ©", "featured": "Destacado", - "find_best_coffee": "Encuentra el mejor cafΓ©", + "find_best_coffee": "Encuentra el Mejor CafΓ©", "popular": "Popular", - "discover_unique_blends": "Descubre mezclas ΓΊnicas" + "discover_unique_blends": "Descubre Mezclas Únicas", + + "producing_since": "produciendo cafΓ© desde {{since}}", + "bio_title": "BiografΓ­a", + "experiences_title": "Experiencias", + "good_practices_title": "Buenas prΓ‘cticas", + "edit_button": "Editar", + + "settings": "Configuraciones", + "language": "Idioma", + "apply": "Aplicar", + + "filter_by": "Filtrar por", + "clear": "Limpiar", + "roast_level": "Grado de tueste", + "region": "RegiΓ³n", + "order_by": "Ordenar por", + "strength": { + "light": "Suave", + "medium": "Medio", + "strong": "Fuerte", + "extra_strong": "Extra Fuerte", + "mild": "Ligero" + }, + "processes": { + "honey": "Miel", + "washed": "Lavado", + "natural": "Natural" + }, + "regions": { + "region_a": "RegiΓ³n A", + "region_b": "RegiΓ³n B", + "region_c": "RegiΓ³n C" + }, + "order": { + "highest_price": "Precio mΓ‘s alto", + "lowest_price": "Precio mΓ‘s bajo" + }, + + "disconnect_confirmation": "ΒΏDesea desconectarse?", + "yes_disconnect": "SΓ­, desconectar", + "cancel": "Cancelar", + "logout_logic": "Por favor, implemente la funcionalidad de cierre de sesiΓ³n.", + + "product_image_alt": "Imagen de {{productName}}", + "order_status": { + "delivered": "Entregado", + "pending": "Pendiente", + "canceled": "Cancelado", + "paid": "Pagado", + "prepared": "Preparado", + "shipped": "Enviado" + }, + "ordered_by": "Pedido por {{name}}", + "price_with_currency": "{{price}} USD", + + "my_collectibles": "Mis coleccionables", + "collectible_title_1": "CafΓ© Especialidad 1", + "collectible_description_1": "DescripciΓ³n del CafΓ© Especialidad 1.", + "collectible_image_alt_1": "Paquete de CafΓ© Especialidad 1", + "collectible_title_2": "CafΓ© Especialidad 2", + "collectible_description_2": "DescripciΓ³n del CafΓ© Especialidad 2.", + "collectible_image_alt_2": "Paquete de CafΓ© Especialidad 2", + "collectible_title_3": "CafΓ© Especialidad 3", + "collectible_description_3": "DescripciΓ³n del CafΓ© Especialidad 3.", + "collectible_image_alt_3": "Paquete de CafΓ© Especialidad 3", + + "product_name_1": "CafΓ© Especialidad 1", + "product_description_1": "DescripciΓ³n del CafΓ© Especialidad 1.", + "product_image_alt_1": "Paquete de CafΓ© Especialidad 1", + "product_name_2": "CafΓ© Especialidad 2", + "product_description_2": "DescripciΓ³n del CafΓ© Especialidad 2.", + "product_image_alt_2": "Paquete de CafΓ© Especialidad 2", + "product_name_3": "CafΓ© Especialidad 3", + "product_description_3": "DescripciΓ³n del CafΓ© Especialidad 3.", + "product_image_alt_3": "Paquete de CafΓ© Especialidad 3", + "product_name_4": "CafΓ© Especialidad 4", + "product_description_4": "DescripciΓ³n del CafΓ© Especialidad 4.", + "product_image_alt_4": "Paquete de CafΓ© Especialidad 4", + "product_name_5": "CafΓ© Especialidad 5", + "product_description_5": "DescripciΓ³n del CafΓ© Especialidad 5.", + "product_image_alt_5": "Paquete de CafΓ© Especialidad 5", + "product_name_6": "CafΓ© Especialidad 6", + "product_description_6": "DescripciΓ³n del CafΓ© Especialidad 6.", + "product_image_alt_6": "Paquete de CafΓ© Especialidad 6", + "product_name_7": "CafΓ© Especialidad 7", + "product_description_7": "DescripciΓ³n del CafΓ© Especialidad 7.", + "product_image_alt_7": "Paquete de CafΓ© Especialidad 7", + + "products_found": "{{count}} productos encontrados", + "clear_search": "Limpiar bΓΊsqueda", + "no_results_image_alt": "Sin resultados", + + "farm": "Granja", + "region_by_farm": "{{region}} por {{farmName}}", + "per_unit": "/unidad", + + "alert": { + "success": "Β‘OperaciΓ³n completada con Γ©xito!", + "error": "OcurriΓ³ un error. Por favor, intente nuevamente.", + "info": "AquΓ­ hay informaciΓ³n importante.", + "dismiss_button": "Cerrar" + }, + + "go_to_slide": "Ir a la diapositiva {{index}}", + + "chat_with_seller": "Chatear con el vendedor", + "bags_available": "Bolsas disponibles", + "bags": "bolsas", + "unit_price": "Precio por unidad ({{weight}})", + "sold_out": "AGOTADO", + "add_to_favorites": "Agregar a favoritos", + "remove_from_favorites": "Eliminar de favoritos", + "edit_my_farm": "Editar mi granja", + "process": "Proceso", + + "about_the_producer": "Acerca del productor", + "farm_bio_placeholder": "DescripciΓ³n de la biografΓ­a de la granja", + "farm_experiences_placeholder": "Experiencias de la granja", + "farm_good_practices_placeholder": "Buenas prΓ‘cticas de la granja", + "producer_avatar_alt": "Avatar del productor", + "farm_icon_alt": "Ícono de la granja", + "reviews": "ReseΓ±as", + "reviews_icon_alt": "Ícono de reseΓ±as", + "star_icon_alt": "Estrella {{starIndex}}", + "sales_on_cofiblocks": "Ventas en Cofiblocks", + "region_icon_alt": "Ícono de regiΓ³n", + "altitude_icon_alt": "Ícono de altitud", + "coordinates_icon_alt": "Ícono de coordenadas", + "website_icon_alt": "Ícono de sitio web", + "altitude": "Altitud", + "altitude_value": "{{altitude}} metros", + "coordinates": "Coordenadas", + "website": "Sitio web", + "sales_icon_alt": "Ícono de ventas", + + "select_coffee_type": "Seleccionar tipo de cafΓ©", + "coffee_type": { + "bean": "Grano", + "grounded": "Molido" + }, + "total": "total", + "adding_to_cart": "AΓ±adiendo al carrito...", + "add_to_cart": "AΓ±adir al carrito", + "shopping_cart_title": "Mi carrito", + "cart_empty_message": "Tu carrito estΓ‘ vacΓ­o", + "quantity_label": "cantidad", + "total_label": "TOTAL", + "buy_button": "Comprar", + "remove_confirmation_title": "ΒΏQuieres eliminar?", + "remove_confirmation_yes": "SΓ­, eliminar", + "aria_label_go_back": "Regresar", + "aria_label_remove_item": "Eliminar {{itemName}} del carrito", + + "search_placeholder": "Busca tu cafΓ©", + "open_filters": "Abrir filtros", + "natural_process": "Natural", + + "edit_my_farm_profile": "Editar el perfil de mi granja", + "loading": "Cargando...", + "farm_profile_image": "Perfil de la Granja", + "no_image": "Sin imagen", + "choose_photo": "Elegir foto", + "farm_name": "Nombre de la Granja", + "altitude_meters": "Altitud (metros)", + "save_changes": "Guardar Cambios", + "farm_name_required": "El nombre de la granja es obligatorio", + "region_required": "La regiΓ³n es obligatoria", + "altitude_positive": "La altitud debe ser un nΓΊmero positivo", + "coordinates_invalid": "Formato de coordenadas invΓ‘lido", + "url_invalid": "URL invΓ‘lida", + "farm_profile_updated": "Perfil de la granja actualizado", + "error_updating_farm": "OcurriΓ³ un error al actualizar el perfil de la granja", + "user_farm_not_found": "No se encontrΓ³ la granja del usuario. Por favor, contacte con soporte.", + "implement_image_upload": "Implementar la lΓ³gica de carga de imΓ‘genes", + + "New": "Nuevo", + "welcome Coffee Lover": "Bienvenido Amante del CafΓ©", + "Featured": "Destacado", + "Find Best Coffee": "Encuentra el Mejor CafΓ©", + "Popular": "Popular", + "Discover Unique Blends": "Descubre Mezclas Únicas", + "Paid": "Pagado", + "Delivered": "Entregado", + "delivered": "Entregado", + + "user_profile": "Perfil de usuario", + "lover": "Amante", + "contributor": "Contribuyente", + "producer": "Productor", + "united_states": "Estados Unidos", + "since": "Desde", + + "edit_my_profile": "Editar mi perfil", + "profile_image": "Perfil", + "full_name": "Nombre completo", + "email": "Correo electrΓ³nico", + "physical_address": "DirecciΓ³n fΓ­sica", + "full_name_required": "El nombre completo es obligatorio", + "invalid_email": "Correo electrΓ³nico invΓ‘lido", + "address_required": "La direcciΓ³n es obligatoria", + "profile_updated": "Perfil actualizado", + "invalidate_user_data_failed": "No se pudo invalidar los datos del usuario", + + "edit_profile": "Editar perfil", + "my_coffee": "Mi CafΓ©", + "my_sales": "Mis Ventas", + "my_claims": "Mis Reclamaciones", + "my_orders": "Mis Pedidos", + "favorite_products": "Productos favoritos", + "wallet": "Billetera", + "wallets": "Billeteras", + "log_out": "Cerrar sesiΓ³n", + + "variety": "Variedad", + "badge_text": "Texto del distintivo", + + "search_seller_placeholder": "Buscar por nombre del vendedor", + "date": "Fecha: {{date}}", + "status": "Estado", + "sample_product": "Producto de muestra", + "strong": "Fuerte", + "grounded": "Molido", + "delivery": "Entrega", + + "october_18": "18 de Octubre", + "september_20": "20 de Septiembre", + "product_name": "Producto de muestra", + + "buyer1_fullname": "Comprador 1", + "buyer2_fullname": "Comprador 2", + "seller1_fullname": "Vendedor 1", + "seller2_fullname": "Vendedor 2", + "delivery_method_label": "MΓ©todo de EnvΓ­o", + "delivery_method": { + "address": "DirecciΓ³n", + "meetup": "Encuentro" + }, + "quantity_with_unit": "{{count}} {{unit}}", + "total_balance_receivable": "Saldo total por recibir", + "recieve": "Recibir", + "history": "Historia", + "my_sellers": "Mis Vendedores", + "offer_coffee": "Ofrecer mi CafΓ©", + "sell_your_coffee": "ΒΏTe gustarΓ­a vender tu cafΓ©?", + "register_coffee": "Registrar mi CafΓ©", + "choose_coffee_photo": "Escoger foto para cafΓ©", + "coffee_variety": "Variedad de cafΓ©", + "type_here": "Escribe aquΓ­", + "coffee_description": "DescripciΓ³n del cafΓ©", + "coffee_score": "PuntuaciΓ³n del CafΓ©", + "not_mandatory": "no obligatorio", + "score_placeholder": "Ingresa una puntuaciΓ³n entre 0 y 100", + "operating_fee": "Tarifa de operaciΓ³n por bolsa", + "price_per_bag": "Precio por bolsa", + "enter_price_placeholder": "Ingresar precio (ej. 25.99)", + "producer_value_per_bag": "Valor asignado al productor por bolsa:", + "total_sales_value_per_bag": "Valor total de ventas (producto + comisiΓ³n) por bolsa:", + "save_and_publish": "Guardar y publicar", + "profile_option_image_alt": "Imagen para {{label}}", + "details": "Detalles", + "shopping_cart": "Carrito de Compras", + "remove": "Quitar", + + "language_name": { + "en": "InglΓ©s", + "es": "EspaΓ±ol", + "pt": "PortuguΓ©s" + } } diff --git a/apps/web/public/locales/pt/common.json b/apps/web/public/locales/pt/common.json index c26fad2..a050b1b 100644 --- a/apps/web/public/locales/pt/common.json +++ b/apps/web/public/locales/pt/common.json @@ -2,11 +2,282 @@ "welcome_to": "Bem-vindo a", "sign": "Assinar", "disconnect": "Desconectar", + "connect_wallet": "Conectar {{walletName}}", + "argent_x": "Argent X", + "error_signing_message": "Erro ao assinar a mensagem", - "welcome_coffee_lover": "Bem-vindo amante de cafΓ©", - "tag_new": "New", - "featured": "Destaque", - "find_best_coffee": "Encontre o melhor cafΓ©", + "tag_new": "Novo", + "welcome_coffee_lover": "Bem-vindo Amante de CafΓ©", + "featured": "Em destaque", + "find_best_coffee": "Encontre o Melhor CafΓ©", "popular": "Popular", - "discover_unique_blends": "Descubra misturas ΓΊnicas" + "discover_unique_blends": "Descubra Misturas Únicas", + + "producing_since": "produzindo cafΓ© desde {{since}}", + "bio_title": "Biografia", + "experiences_title": "ExperiΓͺncias", + "good_practices_title": "Boas prΓ‘ticas", + "edit_button": "Editar", + + "settings": "Configuraçáes", + "language": "Idioma", + "apply": "Aplicar", + + "filter_by": "Filtrar por", + "clear": "Limpar", + "roast_level": "NΓ­vel de torra", + "region": "RegiΓ£o", + "order_by": "Ordenar por", + "strength": { + "light": "Suave", + "medium": "MΓ©dio", + "strong": "Forte", + "extra_strong": "Extra Forte", + "mild": "Leve" + }, + "processes": { + "honey": "Mel", + "washed": "Lavado", + "natural": "Natural" + }, + "regions": { + "region_a": "RegiΓ£o A", + "region_b": "RegiΓ£o B", + "region_c": "RegiΓ£o C" + }, + "order": { + "highest_price": "Maior preΓ§o", + "lowest_price": "Menor preΓ§o" + }, + + "disconnect_confirmation": "Deseja se desconectar?", + "yes_disconnect": "Sim, desconectar", + "cancel": "Cancelar", + "logout_logic": "Por favor, implemente a funcionalidade de logout.", + + "product_image_alt": "Imagem de {{productName}}", + "order_status": { + "delivered": "Entregue", + "pending": "Pendente", + "canceled": "Cancelado", + "paid": "Pago", + "prepared": "Preparado", + "shipped": "Enviado" + }, + "ordered_by": "Pedido por {{name}}", + "price_with_currency": "{{price}} USD", + + "my_collectibles": "Meus colecionΓ‘veis", + "collectible_title_1": "CafΓ© Especial 1", + "collectible_description_1": "Descrição do CafΓ© Especial 1.", + "collectible_image_alt_1": "Pacote do CafΓ© Especial 1", + "collectible_title_2": "CafΓ© Especial 2", + "collectible_description_2": "Descrição do CafΓ© Especial 2.", + "collectible_image_alt_2": "Pacote do CafΓ© Especial 2", + "collectible_title_3": "CafΓ© Especial 3", + "collectible_description_3": "Descrição do CafΓ© Especial 3.", + "collectible_image_alt_3": "Pacote do CafΓ© Especial 3", + + "product_name_1": "CafΓ© Especial 1", + "product_description_1": "Descrição do CafΓ© Especial 1.", + "product_image_alt_1": "Pacote do CafΓ© Especial 1", + "product_name_2": "CafΓ© Especial 2", + "product_description_2": "Descrição do CafΓ© Especial 2.", + "product_image_alt_2": "Pacote do CafΓ© Especial 2", + "product_name_3": "CafΓ© Especial 3", + "product_description_3": "Descrição do CafΓ© Especial 3.", + "product_image_alt_3": "Pacote do CafΓ© Especial 3", + "product_name_4": "CafΓ© Especial 4", + "product_description_4": "Descrição do CafΓ© Especial 4.", + "product_image_alt_4": "Pacote do CafΓ© Especial 4", + "product_name_5": "CafΓ© Especial 5", + "product_description_5": "Descrição do CafΓ© Especial 5.", + "product_image_alt_5": "Pacote do CafΓ© Especial 5", + "product_name_6": "CafΓ© Especial 6", + "product_description_6": "Descrição do CafΓ© Especial 6.", + "product_image_alt_6": "Pacote do CafΓ© Especial 6", + "product_name_7": "CafΓ© Especial 7", + "product_description_7": "Descrição do CafΓ© Especial 7.", + "product_image_alt_7": "Pacote do CafΓ© Especial 7", + + "products_found": "{{count}} produtos encontrados", + "clear_search": "Limpar pesquisa", + "no_results_image_alt": "Sem resultados", + + "farm": "Fazenda", + "region_by_farm": "{{region}} por {{farmName}}", + "per_unit": "/unidade", + + "alert": { + "success": "Operação concluΓ­da com sucesso!", + "error": "Ocorreu um erro. Por favor, tente novamente.", + "info": "Aqui estΓ‘ uma informação importante.", + "dismiss_button": "Fechar" + }, + + "go_to_slide": "Ir para o slide {{index}}", + + "chat_with_seller": "Conversar com o vendedor", + "bags_available": "Sacos disponΓ­veis", + "bags": "sacos", + "unit_price": "PreΓ§o por unidade ({{weight}})", + "sold_out": "ESGOTADO", + "add_to_favorites": "Adicionar aos favoritos", + "remove_from_favorites": "Remover dos favoritos", + "edit_my_farm": "Editar minha fazenda", + "process": "Processo", + + "about_the_producer": "Sobre o produtor", + "farm_bio_placeholder": "Descrição da biografia da fazenda", + "farm_experiences_placeholder": "ExperiΓͺncias da fazenda", + "farm_good_practices_placeholder": "Boas prΓ‘ticas da fazenda", + "producer_avatar_alt": "Avatar do produtor", + "farm_icon_alt": "Ícone da fazenda", + "reviews": "Avaliaçáes", + "reviews_icon_alt": "Ícone de avaliaçáes", + "star_icon_alt": "Estrela {{starIndex}}", + "sales_on_cofiblocks": "Vendas na Cofiblocks", + "region_icon_alt": "Ícone de regiΓ£o", + "altitude_icon_alt": "Ícone de altitude", + "coordinates_icon_alt": "Ícone de coordenadas", + "website_icon_alt": "Ícone do site", + "altitude": "Altitude", + "altitude_value": "{{altitude}} metros", + "coordinates": "Coordenadas", + "website": "Site", + "sales_icon_alt": "Ícone de vendas", + + "select_coffee_type": "Selecionar tipo de cafΓ©", + "coffee_type": { + "bean": "GrΓ£o", + "grounded": "MoΓ­do" + }, + "total": "total", + "adding_to_cart": "Adicionando ao carrinho...", + "add_to_cart": "Adicionar ao carrinho", + "shopping_cart_title": "Meu carrinho", + "cart_empty_message": "Seu carrinho estΓ‘ vazio", + "quantity_label": "quantidade", + "total_label": "TOTAL", + "buy_button": "Comprar", + "remove_confirmation_title": "Deseja remover?", + "remove_confirmation_yes": "Sim, remover", + "aria_label_go_back": "Voltar", + "aria_label_remove_item": "Remover {{itemName}} do carrinho", + + "search_placeholder": "Procure seu cafΓ©", + "open_filters": "Abrir filtros", + "natural_process": "Natural", + + "edit_my_farm_profile": "Editar o perfil da minha fazenda", + "loading": "Carregando...", + "farm_profile_image": "Perfil da Fazenda", + "no_image": "Sem imagem", + "choose_photo": "Escolher foto", + "farm_name": "Nome da Fazenda", + "altitude_meters": "Altitude (metros)", + "save_changes": "Salvar alteraçáes", + "farm_name_required": "O nome da fazenda Γ© obrigatΓ³rio", + "region_required": "A regiΓ£o Γ© obrigatΓ³ria", + "altitude_positive": "A altitude deve ser um nΓΊmero positivo", + "coordinates_invalid": "Formato de coordenadas invΓ‘lido", + "url_invalid": "URL invΓ‘lida", + "farm_profile_updated": "Perfil da fazenda atualizado", + "error_updating_farm": "Ocorreu um erro ao atualizar o perfil da fazenda", + "user_farm_not_found": "Fazenda do usuΓ‘rio nΓ£o encontrada. Por favor, contate o suporte.", + "implement_image_upload": "Implementar lΓ³gica de upload de imagens", + + "New": "Novo", + "welcome Coffee Lover": "Bem-vindo Amante de CafΓ©", + "Featured": "Em destaque", + "Find Best Coffee": "Encontre o Melhor CafΓ©", + "Popular": "Popular", + "Discover Unique Blends": "Descubra Misturas Únicas", + "Paid": "Pago", + "Delivered": "Entregue", + "delivered": "Entregue", + + "user_profile": "Perfil do usuΓ‘rio", + "lover": "Amante", + "contributor": "Contribuidor", + "producer": "Produtor", + "united_states": "Estados Unidos", + "since": "Desde", + + "edit_my_profile": "Editar meu perfil", + "profile_image": "Perfil", + "full_name": "Nome completo", + "email": "E-mail", + "physical_address": "EndereΓ§o fΓ­sico", + "full_name_required": "O nome completo Γ© obrigatΓ³rio", + "invalid_email": "E-mail invΓ‘lido", + "address_required": "O endereΓ§o Γ© obrigatΓ³rio", + "profile_updated": "Perfil atualizado", + "invalidate_user_data_failed": "Falha ao invalidar os dados do usuΓ‘rio", + + "edit_profile": "Editar perfil", + "my_coffee": "Meu CafΓ©", + "my_sales": "Minhas Vendas", + "my_claims": "Minhas Reclamaçáes", + "my_orders": "Meus Pedidos", + "favorite_products": "Produtos favoritos", + "wallet": "Carteira", + "wallets": "Carteiras", + "log_out": "Sair", + + "variety": "Variedade", + "badge_text": "Texto do distintivo", + + "search_seller_placeholder": "Procurar pelo nome do vendedor", + "date": "Data: {{date}}", + "status": "Status", + "sample_product": "Produto de amostra", + "strong": "Forte", + "grounded": "MoΓ­do", + "delivery": "Entrega", + + "october_18": "18 de Outubro", + "september_20": "20 de Setembro", + "product_name": "Produto de amostra", + + "buyer1_fullname": "Comprador 1", + "buyer2_fullname": "Comprador 2", + "seller1_fullname": "Vendedor 1", + "seller2_fullname": "Vendedor 2", + "delivery_method_label": "MΓ©todo de Entrega", + "delivery_method": { + "address": "EndereΓ§o", + "meetup": "Encontro" + }, + "quantity_with_unit": "{{count}} {{unit}}", + "total_balance_receivable": "Saldo total a receber", + "recieve": "Receber", + "history": "HistΓ³rico", + "my_sellers": "Meus Vendedores", + "offer_coffee": "Oferecer meu cafΓ©", + "sell_your_coffee": "Gostaria de vender seu cafΓ©?", + "register_coffee": "Registrar meu CafΓ©", + "choose_coffee_photo": "Escolher foto do cafΓ©", + "coffee_variety": "Variedade de cafΓ©", + "type_here": "Digite aqui", + "coffee_description": "Descrição do cafΓ©", + "coffee_score": "Pontuação do CafΓ©", + "not_mandatory": "nΓ£o obrigatΓ³rio", + "score_placeholder": "Insira uma pontuação entre 0 e 100", + "operating_fee": "Taxa de operação por saco", + "price_per_bag": "PreΓ§o por saco", + "enter_price_placeholder": "Insira o preΓ§o (ex. 25.99)", + "producer_value_per_bag": "Valor destinado ao produtor por saco:", + "total_sales_value_per_bag": "Valor total de vendas (produto + taxa) por saco:", + "save_and_publish": "Salvar e publicar", + "profile_option_image_alt": "Imagem para {{label}}", + "details": "Detalhes", + "shopping_cart": "Carrinho de Compras", + "remove": "Remover", + + "language_name": { + "en": "InglΓͺs", + "es": "Espanhol", + "pt": "PortuguΓͺs" + } } diff --git a/apps/web/src/app/_components/features/FarmModal.tsx b/apps/web/src/app/_components/features/FarmModal.tsx index 0a4a35f..1f0978d 100644 --- a/apps/web/src/app/_components/features/FarmModal.tsx +++ b/apps/web/src/app/_components/features/FarmModal.tsx @@ -1,4 +1,5 @@ import Button from "@repo/ui/button"; +import { useTranslation } from "react-i18next"; import BottomModal from "~/app/_components/ui/BottomModal"; interface FarmModalProps { @@ -22,6 +23,8 @@ function FarmModal({ isEditable, onEdit, }: FarmModalProps) { + const { t } = useTranslation("common"); + return (
@@ -32,24 +35,28 @@ function FarmModal({ {farmData.name}

- producing coffee since {farmData.since} + {t("producing_since", { since: farmData.since })}

-

Bio

+

{t("bio_title")}

{farmData.bio}

-

Experiences

+

+ {t("experiences_title")} +

{farmData.experiences}

-

Good practices

+

+ {t("good_practices_title")} +

{farmData.goodPractices}

@@ -57,7 +64,7 @@ function FarmModal({ {isEditable && ( )}
diff --git a/apps/web/src/app/_components/features/FilterModal.tsx b/apps/web/src/app/_components/features/FilterModal.tsx index c506207..18f10d7 100644 --- a/apps/web/src/app/_components/features/FilterModal.tsx +++ b/apps/web/src/app/_components/features/FilterModal.tsx @@ -2,6 +2,7 @@ import { XMarkIcon } from "@heroicons/react/24/outline"; import { motion } from "framer-motion"; import { useAtom } from "jotai"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { isLoadingAtom, quantityOfProducts, @@ -15,6 +16,7 @@ interface FilterModalProps { } export default function FilterModal({ isOpen, onClose }: FilterModalProps) { + const { t } = useTranslation(); const [selectedStrength, setSelectedStrength] = useState(""); const [selectedRegion, setSelectedRegion] = useState(""); const [selectedOrder, setSelectedOrder] = useState(""); @@ -39,13 +41,18 @@ export default function FilterModal({ isOpen, onClose }: FilterModalProps) { const { data } = await refetch(); if (data?.productsFound) { - setSearchResults(data.productsFound); - setQuantityProducts(data.productsFound.length); + const products = data.productsFound.map((product) => ({ + ...product, + region: product.region, + })); + setSearchResults(products); + setQuantityProducts(products.length); } else { setSearchResults([]); setQuantityProducts(0); } } catch (error) { + console.error(error); setSearchResults([]); setQuantityProducts(0); } finally { @@ -83,14 +90,14 @@ export default function FilterModal({ isOpen, onClose }: FilterModalProps) { >
-

Filter by

+

{t("filter_by")}

{hasActiveFilters && ( )}
@@ -101,7 +108,7 @@ export default function FilterModal({ isOpen, onClose }: FilterModalProps) {
-

Roast level

+

{t("roast_level")}

{["Light", "Medium", "Strong"].map((strength) => ( ))}
-

Region

+

{t("region")}

{["Region A", "Region B", "Region C"].map((region) => ( ))}
-

Order by

+

{t("order_by")}

{["Highest price", "Lowest price"].map((order) => ( ))}
@@ -174,7 +185,7 @@ export default function FilterModal({ isOpen, onClose }: FilterModalProps) { onClick={handleApply} className="w-full bg-surface-secondary-default text-black py-3 px-4 rounded-lg font-medium hover:bg-yellow-500 transition-colors" > - Apply + {t("apply")}
diff --git a/apps/web/src/app/_components/features/LogoutModal.tsx b/apps/web/src/app/_components/features/LogoutModal.tsx index 9cb9fbf..6d159b7 100644 --- a/apps/web/src/app/_components/features/LogoutModal.tsx +++ b/apps/web/src/app/_components/features/LogoutModal.tsx @@ -1,5 +1,6 @@ import Button from "@repo/ui/button"; import { useRouter } from "next/navigation"; +import { useTranslation } from "react-i18next"; import BottomModal from "~/app/_components/ui/BottomModal"; interface LogoutModalProps { @@ -8,11 +9,12 @@ interface LogoutModalProps { } function LogoutModal({ isOpen, onClose }: LogoutModalProps) { + const { t } = useTranslation(); const router = useRouter(); const handleLogout = () => { // TODO: Implement logout/disconnect logic - alert("Implement logout logic"); + alert(t("logout_logic")); onClose(); router.push("/"); }; @@ -20,16 +22,14 @@ function LogoutModal({ isOpen, onClose }: LogoutModalProps) { return (

- Do you want to disconnect? + {t("disconnect_confirmation")}

diff --git a/apps/web/src/app/_components/features/OrderListItem.tsx b/apps/web/src/app/_components/features/OrderListItem.tsx index 3a45186..2232305 100644 --- a/apps/web/src/app/_components/features/OrderListItem.tsx +++ b/apps/web/src/app/_components/features/OrderListItem.tsx @@ -3,6 +3,7 @@ import { ChevronRightIcon } from "@heroicons/react/24/outline"; import Image from "next/image"; import Link from "next/link"; +import { useTranslation } from "react-i18next"; interface OrderListItemProps { productName: string; @@ -17,6 +18,8 @@ export default function OrderListItem({ status, onClick, }: OrderListItemProps) { + const { t } = useTranslation(); + return (
Product - {status} + {t(`order_status.${status.toLowerCase()}`)}
diff --git a/apps/web/src/app/_components/features/OrderListPriceItem.tsx b/apps/web/src/app/_components/features/OrderListPriceItem.tsx index 0fb900b..4298a74 100644 --- a/apps/web/src/app/_components/features/OrderListPriceItem.tsx +++ b/apps/web/src/app/_components/features/OrderListPriceItem.tsx @@ -3,6 +3,7 @@ import { ChevronRightIcon } from "@heroicons/react/24/outline"; import Image from "next/image"; import Link from "next/link"; +import { useTranslation } from "react-i18next"; interface OrderListItemProps { productName: string; @@ -17,6 +18,8 @@ export default function OrderListPriceItem({ price, onClick, }: OrderListItemProps) { + const { t } = useTranslation(); + return (
Product

{productName}

-

{name}

+

{t("ordered_by", { name })}

- {price} USD + + {t("price_with_currency", { price })} +
diff --git a/apps/web/src/app/_components/features/ProducerInfo.tsx b/apps/web/src/app/_components/features/ProducerInfo.tsx index b8249b5..4b0d683 100644 --- a/apps/web/src/app/_components/features/ProducerInfo.tsx +++ b/apps/web/src/app/_components/features/ProducerInfo.tsx @@ -2,6 +2,7 @@ import { ChevronRightIcon } from "@heroicons/react/24/outline"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { FarmModal } from "./FarmModal"; interface ProducerInfoProps { @@ -33,13 +34,14 @@ export function ProducerInfo({ }: ProducerInfoProps) { const [isFarmModalOpen, setIsFarmModalOpen] = useState(false); const router = useRouter(); + const { t } = useTranslation(); const farmData = { name: farmName, since: farmSince ?? "2020", - bio: farmBio ?? "Farm bio description", - experiences: farmExperiences ?? "Farm experiences", - goodPractices: farmGoodPractices ?? "Farm good practices", + bio: farmBio ?? t("farm_bio_placeholder"), + experiences: farmExperiences ?? t("farm_experiences_placeholder"), + goodPractices: farmGoodPractices ?? t("farm_good_practices_placeholder"), }; const openFarmModal = () => setIsFarmModalOpen(true); @@ -50,14 +52,14 @@ export function ProducerInfo({
Producer Avatar

- About the producer + {t("about_the_producer")}

Farm icon
- Farm + {t("farm")}
@@ -96,14 +98,14 @@ export function ProducerInfo({
Reviews icon
- Reviews + {t("reviews")}
@@ -115,7 +117,7 @@ export function ProducerInfo({ ? "/images/product-details/producer-info/Star-highlighted.svg" : "/images/product-details/producer-info/Star.svg" } - alt={`Star ${starIndex}`} + alt={t("star_icon_alt", { starIndex })} width={24} height={24} /> @@ -128,14 +130,14 @@ export function ProducerInfo({
Sales icon
- Sales on Cofiblocks + {t("sales_on_cofiblocks")}
@@ -148,14 +150,14 @@ export function ProducerInfo({
Location icon
- Region + {t("region")}
@@ -168,19 +170,19 @@ export function ProducerInfo({
Altitude icon
- Altitude + {t("altitude")}
- {altitude} metros + {t("altitude_value", { altitude })}
@@ -190,14 +192,14 @@ export function ProducerInfo({
Coordinates icon
- Coordinates + {t("coordinates")}
@@ -220,14 +222,14 @@ export function ProducerInfo({
Website icon
- Website + {t("website")}
diff --git a/apps/web/src/app/_components/features/ProductCatalog.tsx b/apps/web/src/app/_components/features/ProductCatalog.tsx index 948f794..a423ea8 100755 --- a/apps/web/src/app/_components/features/ProductCatalog.tsx +++ b/apps/web/src/app/_components/features/ProductCatalog.tsx @@ -5,6 +5,7 @@ import { useAtom } from "jotai"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import { isLoadingAtom, quantityOfProducts, @@ -15,6 +16,7 @@ import { api } from "~/trpc/react"; import type { NftMetadata, Product } from "./types"; export default function ProductCatalog() { + const { t } = useTranslation(); const [products, setProducts] = useState([]); const [results, setSearchResults] = useAtom(searchResultsAtom); const [isLoadingResults, setIsLoading] = useAtom(isLoadingAtom); @@ -22,76 +24,70 @@ export default function ProductCatalog() { const [query, setQuery] = useAtom(searchQueryAtom); const router = useRouter(); - // Using an infinite query to fetch products with pagination const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = api.product.getProducts.useInfiniteQuery( { - limit: 3, // Fetch 3 products per request + limit: 3, }, { getNextPageParam: (lastPage) => lastPage.nextCursor, }, ); - // Effect to update local state whenever new data is loaded useEffect(() => { if (data) { const allProducts = data.pages.flatMap((page) => page.products.map((product) => ({ ...product, - process: product.process ?? "Natural", + process: "Natural", })), ); setProducts(allProducts); } }, [data]); - // Handle infinite scroll for fetching more products as the user scrolls down const handleScroll = useCallback(() => { if ( - window.innerHeight + window.scrollY >= document.body.offsetHeight - 100 && // Check if near bottom + window.innerHeight + window.scrollY >= document.body.offsetHeight - 100 && !isFetchingNextPage && hasNextPage ) { - void fetchNextPage(); // Fetch the next set of products + void fetchNextPage(); } }, [isFetchingNextPage, hasNextPage, fetchNextPage]); - // Attach and detach scroll event listeners useEffect(() => { window.addEventListener("scroll", handleScroll); return () => window.removeEventListener("scroll", handleScroll); }, [handleScroll]); - // Placeholder for adding products to the cart const accessProductDetails = (productId: number) => { - router.push(`/product/${productId}`); // Navigate to the product details page + router.push(`/product/${productId}`); }; - // Render each product const renderProduct = (product: Product) => { let metadata: NftMetadata | null = null; if (typeof product.nftMetadata === "string") { try { - metadata = JSON.parse(product.nftMetadata) as NftMetadata; // Try to parse if it's a JSON string + metadata = JSON.parse(product.nftMetadata) as NftMetadata; } catch { - metadata = null; // Fallback in case of an error + metadata = null; } } else { - metadata = product.nftMetadata as NftMetadata; // Assign directly if it's already an object + metadata = product.nftMetadata as NftMetadata; } return ( accessProductDetails(product.id)} // Trigger add-to-cart action + badgeText={t(`strength.${metadata?.strength.toLowerCase()}`)} + onClick={() => accessProductDetails(product.id)} /> ); }; @@ -112,13 +108,13 @@ export default function ProductCatalog() { {results.length > 0 ? (
-
{quantity} products found
+
{t("products_found", { count: quantity })}
{results.map((product) => ( @@ -132,7 +128,7 @@ export default function ProductCatalog() { src="/images/NoResultsImage.png" width={700} height={700} - alt="No results" + alt={t("no_results_image_alt")} />
@@ -141,7 +137,7 @@ export default function ProductCatalog() { size="sm" onClick={() => clearSearch()} > - Clear search + {t("clear_search")}
diff --git a/apps/web/src/app/_components/features/ProductDetails.tsx b/apps/web/src/app/_components/features/ProductDetails.tsx index b155b8c..41a0ab0 100644 --- a/apps/web/src/app/_components/features/ProductDetails.tsx +++ b/apps/web/src/app/_components/features/ProductDetails.tsx @@ -1,13 +1,13 @@ import { HeartIcon } from "@heroicons/react/24/outline"; import { HeartIcon as HeartSolidIcon } from "@heroicons/react/24/solid"; import Button from "@repo/ui/button"; -import { ChatWithSeller } from "@repo/ui/chatWithSeller"; import { DataCard } from "@repo/ui/dataCard"; import PageHeader from "@repo/ui/pageHeader"; import { useAtom, useAtomValue } from "jotai"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { addItemAtom, cartItemsAtom } from "~/store/cartAtom"; import { ProducerInfo } from "./ProducerInfo"; import { SelectionTypeCard } from "./SelectionTypeCard"; @@ -15,6 +15,7 @@ import { SelectionTypeCard } from "./SelectionTypeCard"; interface ProductDetailsProps { product: { id: number; + tokenId: number; image: string; name: string; region: string; @@ -39,6 +40,7 @@ export default function ProductDetails({ product }: ProductDetailsProps) { type, process, } = product; + const { t } = useTranslation(); const [quantity, setQuantity] = useState(1); const [isLiked, setIsLiked] = useState(false); const router = useRouter(); @@ -57,6 +59,7 @@ export default function ProductDetails({ product }: ProductDetailsProps) { setIsAddingToCart(true); addItem({ id: String(product.id), + tokenId: product.tokenId, name: product.name, quantity: quantity, price: product.price, @@ -66,10 +69,12 @@ export default function ProductDetails({ product }: ProductDetailsProps) { }; return ( -
+
{name}
} + title={ +
{t(product.name)}
+ } showBackButton onBackClick={() => router.back()} showCart={true} @@ -80,7 +85,7 @@ export default function ProductDetails({ product }: ProductDetailsProps) { onClick={() => setIsLiked(!isLiked)} className="p-2" aria-label={ - isLiked ? "Remove from favorites" : "Add to favorites" + isLiked ? t("remove_from_favorites") : t("add_to_favorites") } > {isLiked ? ( @@ -99,45 +104,42 @@ export default function ProductDetails({ product }: ProductDetailsProps) { alt={name} width={358} height={358} - className="object-cover w-full h-full" + className="h-full w-full object-cover" />
-

{name}

-

- {product.description} +

+ {t(product.name)} +

+

+ {t(product.description)}

-
- console.log("Open chat")} - /> -
-
+
-
+
@@ -156,8 +158,8 @@ export default function ProductDetails({ product }: ProductDetailsProps) {
)} -
-
+
+
@@ -172,8 +174,8 @@ export default function ProductDetails({ product }: ProductDetailsProps) { isEditable={true} /> -
diff --git a/apps/web/src/app/_components/features/ProductDetailsList.tsx b/apps/web/src/app/_components/features/ProductDetailsList.tsx new file mode 100644 index 0000000..0337fa8 --- /dev/null +++ b/apps/web/src/app/_components/features/ProductDetailsList.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +type ProductDetail = { + label: string; + value: string; + address?: string; +}; + +type ProductDetailsListProps = { + details: ProductDetail[]; +}; + +export function ProductDetailsList({ details }: ProductDetailsListProps) { + return ( +
+ {details.map((item, index, array) => ( + +
+
+

{item.label}

+ {item.address && ( +

+ {item.address} +

+ )} +
+

{item.value}

+
+ {index < array.length - 1 &&
} +
+ ))} +
+ ); +} diff --git a/apps/web/src/app/_components/features/ProductList.tsx b/apps/web/src/app/_components/features/ProductList.tsx index eb5ca11..19be17f 100644 --- a/apps/web/src/app/_components/features/ProductList.tsx +++ b/apps/web/src/app/_components/features/ProductList.tsx @@ -5,6 +5,7 @@ import { addItemAtom } from "~/store/cartAtom"; interface Product { id: number; + tokenId: number; name: string; price: number; description: string; @@ -20,6 +21,7 @@ export default function ProductList({ products }: ProductListProps) { const handleAddToCart = (product: Product) => { addItem({ id: String(product.id), + tokenId: product.tokenId, name: product.name, quantity: 1, price: product.price, diff --git a/apps/web/src/app/_components/features/ProductStatusDetails.tsx b/apps/web/src/app/_components/features/ProductStatusDetails.tsx index b7ff3f8..30a9745 100644 --- a/apps/web/src/app/_components/features/ProductStatusDetails.tsx +++ b/apps/web/src/app/_components/features/ProductStatusDetails.tsx @@ -6,15 +6,14 @@ import { TruckIcon, WalletIcon, } from "@heroicons/react/24/outline"; -import { LightBulbIcon } from "@heroicons/react/24/solid"; import { zodResolver } from "@hookform/resolvers/zod"; -import Button from "@repo/ui/button"; -import RadioButton from "@repo/ui/form/radioButton"; import Image from "next/image"; import React, { useState, useEffect } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; -import BottomModal from "~/app/_components/ui/BottomModal"; +import { ProductDetailsList } from "./ProductDetailsList"; +import { StatusBanner } from "./StatusBanner"; +import { StatusUpdateModal } from "./StatusUpdateModal"; type ProductDetails = { productName: string; @@ -27,7 +26,7 @@ type ProductDetails = { address?: string; }; -enum StatusStepsEnum { +export enum StatusStepsEnum { Paid = "Paid", Shipped = "Shipped", Prepared = "Prepared", @@ -43,8 +42,6 @@ const orderStatusSchema = z.object({ status: z.nativeEnum(StatusStepsEnum), }); -type FormValues = z.infer; - interface ProductStatusDetailsProps { productDetails: ProductDetails; isProducer: boolean; @@ -56,13 +53,12 @@ export default function ProductStatusDetails({ isProducer, updateProductDetails, }: ProductStatusDetailsProps) { - const [isOrderStatusModalOpen, setIsOrderStatusModalOpen] = - useState(false); + const [isOrderStatusModalOpen, setIsOrderStatusModalOpen] = useState(false); - const { control, handleSubmit, setValue } = useForm({ - defaultValues: { - status: StatusStepsEnum.Paid, - }, + const { control, handleSubmit, setValue } = useForm<{ + status: StatusStepsEnum; + }>({ + defaultValues: { status: StatusStepsEnum.Paid }, resolver: zodResolver(orderStatusSchema), }); @@ -74,64 +70,68 @@ export default function ProductStatusDetails({ const statusStepsKeys = Object.keys(StatusStepsEnum); - const openOrderStatusModal = () => { - setIsOrderStatusModalOpen(true); - }; + const closeOrderStatusModal = () => setIsOrderStatusModalOpen(false); - const closeOrderStatusModal = () => { - setIsOrderStatusModalOpen(false); - }; - - const onSubmit = (data: FormValues) => { + const onSubmit = (data: { status: StatusStepsEnum }) => { if (productDetails && updateProductDetails) { - updateProductDetails({ - ...productDetails, - status: data.status, - }); + updateProductDetails({ ...productDetails, status: data.status }); } - closeOrderStatusModal(); }; + const handleSubmitForm = handleSubmit(onSubmit); + if (!productDetails) return
Loading...
; - const ProductStatus = ({ - productDetails, - }: { productDetails: ProductDetails }) => { - const stepsByDeliveryType = { - [DeliveryTypeEnum.Meetup]: [ - StatusStepsEnum.Paid, - StatusStepsEnum.Prepared, - StatusStepsEnum.Delivered, - ], - [DeliveryTypeEnum.Delivery]: [ - StatusStepsEnum.Paid, - StatusStepsEnum.Shipped, - StatusStepsEnum.Delivered, - ], - }; - const statusSteps = - stepsByDeliveryType[productDetails.delivery as DeliveryTypeEnum]; - const currentStepIndex = statusSteps.indexOf( - productDetails.status as StatusStepsEnum, - ); - const stepIconMap = { - [StatusStepsEnum.Paid]: WalletIcon, - [StatusStepsEnum.Shipped]: TruckIcon, - [StatusStepsEnum.Prepared]: ShoppingBagIcon, - [StatusStepsEnum.Delivered]: CheckCircleIcon, - }; + const stepsByDeliveryType = { + [DeliveryTypeEnum.Meetup]: [ + StatusStepsEnum.Paid, + StatusStepsEnum.Prepared, + StatusStepsEnum.Delivered, + ], + [DeliveryTypeEnum.Delivery]: [ + StatusStepsEnum.Paid, + StatusStepsEnum.Shipped, + StatusStepsEnum.Delivered, + ], + }; + + const statusSteps = + stepsByDeliveryType[productDetails.delivery as DeliveryTypeEnum]; + const currentStepIndex = statusSteps.indexOf( + productDetails.status as StatusStepsEnum, + ); + + const stepIconMap = { + [StatusStepsEnum.Paid]: WalletIcon, + [StatusStepsEnum.Shipped]: TruckIcon, + [StatusStepsEnum.Prepared]: ShoppingBagIcon, + [StatusStepsEnum.Delivered]: CheckCircleIcon, + }; + + return ( +
+
+ +
+ +
+ Product +

{productDetails.productName}

+
- return ( -
+
{statusSteps.map((step, index) => ( -
+
))}
- ); - }; - - const OrderStatusBanner = ({ - orderStatus, - isProducer, - }: { orderStatus: string; isProducer: boolean }) => { - const statusText = { - [StatusStepsEnum.Paid]: "The Producer is already preparing your order", - [StatusStepsEnum.Shipped]: "The Producer has already shipped your order", - [StatusStepsEnum.Prepared]: - "The Producer has already prepared your order", - [StatusStepsEnum.Delivered]: "Your order has arrived. Enjoy your coffee", - }; - - if (isProducer) { - if (orderStatus === (StatusStepsEnum.Delivered as string)) { - return; - } - return ( -
- -
-

- New order -

-

- You have a new order. Let's start the preparations. -

-
- -
- ); - } - - return ( -
-
-

- - {statusText[orderStatus as keyof typeof statusText] || ""} -

-
-
- ); - }; - - return ( -
-
- -
- -
- Product -

{productDetails.productName}

-
- -
- -
- {isProducer && ( - - )} - -
- {[ + ( - -
-
-

{item.label}

- {item.address && ( -

- {item.address} -

- )} -
-

{item.value}

-
- {index < array.length - 1 &&
} -
- ))} -
+ ]} + /> {isProducer && ( - -

- Select the status -

-
-
- {statusStepsKeys.map((status, index) => ( - - - {index < statusStepsKeys.length - 1 && ( -
- )} -
- ))} -
- -
-
+ onSubmit={handleSubmitForm} + control={control} + statusStepsKeys={statusStepsKeys} + /> )}
); diff --git a/apps/web/src/app/_components/features/ProfileCard.tsx b/apps/web/src/app/_components/features/ProfileCard.tsx index 437756c..52e6337 100644 --- a/apps/web/src/app/_components/features/ProfileCard.tsx +++ b/apps/web/src/app/_components/features/ProfileCard.tsx @@ -1,6 +1,7 @@ import Image from "next/image"; +import { useTranslation } from "react-i18next"; -type Badge = "Lover" | "Contributor" | "Producer"; +type Badge = "lover" | "contributor" | "producer"; type UserProfile = { name: string; @@ -15,7 +16,10 @@ type ProfileCardProps = { }; function ProfileCard({ user }: ProfileCardProps) { - const allBadges: Badge[] = ["Lover", "Contributor", "Producer"]; + const { t } = useTranslation(); + + // Define all badges and their translation keys + const allBadges: Badge[] = ["lover", "contributor", "producer"]; return (
@@ -32,7 +36,9 @@ function ProfileCard({ user }: ProfileCardProps) {

{user.name}

{user.country}

-

Since {user.memberSince}

+

+ {t("since")} {user.memberSince} +

@@ -40,13 +46,16 @@ function ProfileCard({ user }: ProfileCardProps) { {allBadges.map((badge) => (
{badge} -

{badge}

+ {/* Translate the badge name */} +

{t(badge)}

))}
diff --git a/apps/web/src/app/_components/features/ProfileOptions.tsx b/apps/web/src/app/_components/features/ProfileOptions.tsx index 39f8bbb..1ff781c 100644 --- a/apps/web/src/app/_components/features/ProfileOptions.tsx +++ b/apps/web/src/app/_components/features/ProfileOptions.tsx @@ -15,6 +15,7 @@ import { cva } from "class-variance-authority"; import cx from "classnames"; import Link from "next/link"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { LogoutModal } from "~/app/_components/features/LogoutModal"; import { UserWalletsModal } from "~/app/_components/features/UserWalletsModal"; @@ -59,6 +60,7 @@ const iconStyles = cva("w-5 h-5 mr-3", { }); function ProfileOptions({ address: _ }: ProfileOptionsProps) { + const { t } = useTranslation(); const [isLogoutModalOpen, setIsLogoutModalOpen] = useState(false); const [isWalletModalOpen, setIsWalletModalOpen] = useState(false); @@ -79,22 +81,26 @@ function ProfileOptions({ address: _ }: ProfileOptionsProps) { }; const profileOptions: ProfileOption[] = [ - { icon: UserIcon, label: "Edit profile", href: "/user/edit-profile" }, - { icon: TicketIcon, label: "My Coffee", href: "/user/my-coffee" }, - { icon: TruckIcon, label: "My Sales", href: "/user/my-sales" }, - { icon: CurrencyDollarIcon, label: "My Claims", href: "/user/my-claims" }, - { icon: ShoppingCartIcon, label: "My Orders", href: "/user/my-orders" }, - { icon: HeartIcon, label: "Favorite products", href: "/user/favorites" }, - { icon: CubeIcon, label: "My collectibles", href: "/user/collectibles" }, - { icon: WalletIcon, label: "Wallet", onClick: openWalletModal }, + { icon: UserIcon, label: t("edit_profile"), href: "/user/edit-profile" }, + { icon: TicketIcon, label: t("my_coffee"), href: "/user/my-coffee" }, + { icon: TruckIcon, label: t("my_sales"), href: "/user/my-sales" }, + { + icon: CurrencyDollarIcon, + label: t("my_claims"), + href: "/user/my-claims", + }, + { icon: ShoppingCartIcon, label: t("my_orders"), href: "/user/my-orders" }, + { icon: HeartIcon, label: t("favorite_products"), href: "/user/favorites" }, + { icon: CubeIcon, label: t("my_collectibles"), href: "/user/collectibles" }, + { icon: WalletIcon, label: t("wallet"), onClick: openWalletModal }, { icon: AdjustmentsHorizontalIcon, - label: "Settings", + label: t("settings"), href: "/user/settings", }, { icon: NoSymbolIcon, - label: "Log out", + label: t("log_out"), customClass: "text-error-default", iconColor: "text-error-default", onClick: openLogoutModal, diff --git a/apps/web/src/app/_components/features/SearchBar.tsx b/apps/web/src/app/_components/features/SearchBar.tsx index 35639fd..6282ad8 100644 --- a/apps/web/src/app/_components/features/SearchBar.tsx +++ b/apps/web/src/app/_components/features/SearchBar.tsx @@ -4,6 +4,7 @@ import InputField from "@repo/ui/form/inputField"; import { useAtom } from "jotai"; import React, { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; import { z } from "zod"; import { isLoadingAtom, @@ -26,6 +27,7 @@ export default function SearchBar() { const [, setQuantityProducts] = useAtom(quantityOfProducts); const [, setIsLoading] = useAtom(isLoadingAtom); const [isFilterOpen, setIsFilterOpen] = useState(false); + const { t } = useTranslation(); const { control } = useForm({ resolver: zodResolver(searchSchema), @@ -44,7 +46,7 @@ export default function SearchBar() { if (data?.productsFound) { const productsWithProcess = data.productsFound.map((product) => ({ ...product, - process: product.process ?? "Natural", + process: product.process ?? t("natural_process"), })); setSearchResults(productsWithProcess); setQuantityProducts(productsWithProcess.length); @@ -52,7 +54,7 @@ export default function SearchBar() { setSearchResults([]); setQuantityProducts(0); } - }, [data, isLoading, setIsLoading, setQuantityProducts, setSearchResults]); + }, [data, isLoading, setIsLoading, setQuantityProducts, setSearchResults, t]); const handleInputChange = (value: string) => { setQuery(value); @@ -65,7 +67,7 @@ export default function SearchBar() { name="region" control={control} label="" - placeholder="Search for your coffee" + placeholder={t("search_placeholder")} onChange={(value: string) => handleInputChange(value)} className="gap-0 mr-3 w-3/4" showSearchIcon={true} @@ -74,6 +76,7 @@ export default function SearchBar() { type="button" onClick={() => setIsFilterOpen(true)} className="bg-surface-secondary-default p-3.5 rounded-lg" + aria-label={t("open_filters")} > diff --git a/apps/web/src/app/_components/features/SelectionTypeCard.tsx b/apps/web/src/app/_components/features/SelectionTypeCard.tsx index 5ccba09..3623b24 100644 --- a/apps/web/src/app/_components/features/SelectionTypeCard.tsx +++ b/apps/web/src/app/_components/features/SelectionTypeCard.tsx @@ -2,6 +2,8 @@ import Button from "@repo/ui/button"; import { InfoCard } from "@repo/ui/infoCard"; import { Text } from "@repo/ui/typography"; import { useState } from "react"; +import toast from "react-hot-toast"; +import { useTranslation } from "react-i18next"; interface SelectionTypeCardProps { price: number; @@ -24,32 +26,41 @@ export function SelectionTypeCard({ "bean", ); + const { t } = useTranslation(); + const coffeeOptions = [ { - label: "Bean", + label: t("coffee_type.bean"), iconSrc: "/images/product-details/Menu-4.svg", selected: selectedOption === "bean", onClick: () => setSelectedOption("bean"), }, { - label: "Grounded", + label: t("coffee_type.grounded"), iconSrc: "/images/product-details/Menu-4.svg", selected: selectedOption === "grounded", onClick: () => setSelectedOption("grounded"), }, ]; + const handleAddToCart = () => { + onAddToCart(); + toast.success("Product added to cart successfully!"); + }; + return ( - +
- Unit price (340g): {price} USD + {t("unit_price", { weight: "340g" })}: {price} USD
{price * quantity} USD - /total + + /{t("total")} +
@@ -77,8 +88,12 @@ export function SelectionTypeCard({
- ); diff --git a/apps/web/src/app/_components/features/ShoppingCart.tsx b/apps/web/src/app/_components/features/ShoppingCart.tsx index 8607f94..5094da5 100644 --- a/apps/web/src/app/_components/features/ShoppingCart.tsx +++ b/apps/web/src/app/_components/features/ShoppingCart.tsx @@ -3,6 +3,7 @@ import { XMarkIcon } from "@heroicons/react/24/solid"; import { useAtom, useAtomValue } from "jotai"; import { useRouter } from "next/navigation"; +import { useTranslation } from "react-i18next"; import { cartItemsAtom, removeItemAtom } from "~/store/cartAtom"; interface ShoppingCartProps { @@ -10,6 +11,7 @@ interface ShoppingCartProps { } export default function ShoppingCart({ closeCart }: ShoppingCartProps) { + const { t } = useTranslation(); const router = useRouter(); const items = useAtomValue(cartItemsAtom); const [, removeItem] = useAtom(removeItemAtom); @@ -31,7 +33,7 @@ export default function ShoppingCart({ closeCart }: ShoppingCartProps) { return (
-

Shopping Cart

+

{t("shopping_cart")}

@@ -42,7 +44,7 @@ export default function ShoppingCart({ closeCart }: ShoppingCartProps) {

{item.name}

${item.price}

))} diff --git a/apps/web/src/app/_components/features/StatusBanner.tsx b/apps/web/src/app/_components/features/StatusBanner.tsx new file mode 100644 index 0000000..c9045c1 --- /dev/null +++ b/apps/web/src/app/_components/features/StatusBanner.tsx @@ -0,0 +1,49 @@ +import { LightBulbIcon } from "@heroicons/react/24/solid"; +import React from "react"; + +type StatusBannerProps = { + orderStatus: string; + isProducer: boolean; +}; + +const statusText = { + Paid: "The Producer is already preparing your order", + Shipped: "The Producer has already shipped your order", + Prepared: "The Producer has already prepared your order", + Delivered: "Your order has arrived. Enjoy your coffee", +}; + +export function StatusBanner({ orderStatus, isProducer }: StatusBannerProps) { + if (isProducer && orderStatus !== "Delivered") { + return ( +
+ +
+

+ New order +

+

+ You have a new order. Let's start the preparations. +

+
+ +
+ ); + } + + return ( +
+
+

+ + {statusText[orderStatus as keyof typeof statusText] || ""} +

+
+
+ ); +} diff --git a/apps/web/src/app/_components/features/StatusUpdateModal.tsx b/apps/web/src/app/_components/features/StatusUpdateModal.tsx new file mode 100644 index 0000000..82cce73 --- /dev/null +++ b/apps/web/src/app/_components/features/StatusUpdateModal.tsx @@ -0,0 +1,56 @@ +import Button from "@repo/ui/button"; +import RadioButton from "@repo/ui/form/radioButton"; +import React, { type FormEvent } from "react"; +import type { Control } from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import BottomModal from "~/app/_components/ui/BottomModal"; +import type { StatusStepsEnum } from "./ProductStatusDetails"; + +type StatusUpdateModalProps = { + isOpen: boolean; + onClose: () => void; + onSubmit: (e?: FormEvent) => Promise; + control: Control<{ status: StatusStepsEnum }>; + statusStepsKeys: string[]; +}; + +export function StatusUpdateModal({ + isOpen, + onClose, + onSubmit, + control, + statusStepsKeys, +}: StatusUpdateModalProps) { + const { t } = useTranslation(); + return ( + +

+ Select the status +

+
+ {" "} + {} +
+ {statusStepsKeys.map((status, index) => ( + + + {index < statusStepsKeys.length - 1 && ( +
+ )} +
+ ))} +
+ +
+
+ ); +} diff --git a/apps/web/src/app/_components/features/UserWalletsModal.tsx b/apps/web/src/app/_components/features/UserWalletsModal.tsx index 60ae525..e250873 100644 --- a/apps/web/src/app/_components/features/UserWalletsModal.tsx +++ b/apps/web/src/app/_components/features/UserWalletsModal.tsx @@ -1,6 +1,7 @@ import { ChevronRightIcon } from "@heroicons/react/24/outline"; import Image from "next/image"; import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import BottomModal from "~/app/_components/ui/BottomModal"; interface UserWalletsModalProps { @@ -16,6 +17,7 @@ interface Wallet { function UserWalletsModal({ isOpen, onClose }: UserWalletsModalProps) { const [wallets, setWallets] = useState([]); + const { t } = useTranslation(); useEffect(() => { // TODO: Implement wallet fetching logic @@ -37,7 +39,7 @@ function UserWalletsModal({ isOpen, onClose }: UserWalletsModalProps) { return (

- Wallets + {t("wallets")}

{wallets.map((wallet) => ( diff --git a/apps/web/src/app/_components/features/types.ts b/apps/web/src/app/_components/features/types.ts index dc8755b..ef447ed 100644 --- a/apps/web/src/app/_components/features/types.ts +++ b/apps/web/src/app/_components/features/types.ts @@ -4,16 +4,17 @@ export type NftMetadata = { imageUrl: string; imageAlt: string; description: string; + strength: string; + farmName: string; + region: string; }; export type Product = { id: number; + tokenId: number; name: string; price: number; nftMetadata: Prisma.JsonValue | NftMetadata; - region: string; - farmName: string; - strength: string; process?: string; createdAt: Date; updatedAt: Date; diff --git a/apps/web/src/app/api/files/route.ts b/apps/web/src/app/api/files/route.ts new file mode 100644 index 0000000..d64b8ad --- /dev/null +++ b/apps/web/src/app/api/files/route.ts @@ -0,0 +1,18 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { pinata } from "../../../utils/pinata"; + +export async function POST(request: NextRequest) { + try { + const data = await request.formData(); + const file: File | null = data.get("file") as unknown as File; + const uploadData = await pinata.upload.file(file); + const url = await pinata.gateways.convert(uploadData.IpfsHash); + return NextResponse.json(url, { status: 200 }); + } catch (e) { + console.log(e); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/app/api/product/[id]/route.ts b/apps/web/src/app/api/product/[id]/route.ts index 9727590..a190170 100644 --- a/apps/web/src/app/api/product/[id]/route.ts +++ b/apps/web/src/app/api/product/[id]/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from "next/server"; -import { mockedProducts } from "~/server/api/routers/mockProducts"; +import { api } from "~/trpc/server"; export async function GET( request: Request, { params }: { params: { id: string } }, ) { const id = Number.parseInt(params.id); - const product = mockedProducts.find((p) => p.id === id); + const product = await api.product.getProductById({ id }); if (!product) { return NextResponse.json({ error: "Product not found" }, { status: 404 }); diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index a848030..5bb763c 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,14 +1,27 @@ "use client"; - +import { useEffect } from "react"; +import { Toaster } from "react-hot-toast"; import "~/styles/globals.css"; +import "~/i18n"; +import { CheckCircleIcon } from "@heroicons/react/20/solid"; import { GeistSans } from "geist/font/sans"; import { SessionProvider } from "next-auth/react"; +import i18n from "~/i18n"; import StarknetProvider from "~/providers/starknet"; import { TRPCReactProvider } from "~/trpc/react"; export default function RootLayout({ children, -}: Readonly<{ children: React.ReactNode }>) { +}: { + children: React.ReactNode; +}) { + useEffect(() => { + const savedLanguage = localStorage.getItem("app_language"); + if (savedLanguage) { + void i18n.changeLanguage(savedLanguage); + } + }, []); + return ( {children} + , + }, + error: { + style: { + backgroundColor: "#E9F1EF", + color: "#3B3E3F", + }, + icon: , + }, + }} + /> ); diff --git a/apps/web/src/app/marketplace/page.tsx b/apps/web/src/app/marketplace/page.tsx index 5953fae..bd9b663 100755 --- a/apps/web/src/app/marketplace/page.tsx +++ b/apps/web/src/app/marketplace/page.tsx @@ -19,20 +19,20 @@ export default function Home() { const carouselData = [ { id: "1", - tag: t("tag_new"), - title: t("welcome_coffee_lover"), + tag: t("tag_new", { ns: "common" }), + title: t("welcome_coffee_lover", { ns: "common" }), image: "/images/carousel1.webp", }, { id: "2", - tag: t("featured"), - title: t("find_best_coffee"), + tag: t("featured", { ns: "common" }), + title: t("find_best_coffee", { ns: "common" }), image: "/images/carousel2.webp", }, { id: "3", - tag: t("popular"), - title: t("discover_unique_blends"), + tag: t("popular", { ns: "common" }), + title: t("discover_unique_blends", { ns: "common" }), image: "/images/carousel3.webp", }, ]; diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index b0ab5c8..e04f9d2 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -10,7 +10,7 @@ import { useDisconnect, useSignTypedData, } from "@starknet-react/core"; -import { type AnimationControls, motion, useAnimation } from "framer-motion"; +import { motion, useAnimation } from "framer-motion"; import { signIn, signOut } from "next-auth/react"; import { useSession } from "next-auth/react"; import { redirect } from "next/navigation"; @@ -55,7 +55,7 @@ export default function LoginPage() { signature, }); } catch (err) { - console.error("Error signing message:", err); + console.error(t("error_signing_message"), err); } }; @@ -161,7 +161,7 @@ export default function LoginPage() { onClick={handleSignMessage} variant="primary" size="lg" - className="w-full max-w-[15rem] px-4 py-3 text-content-title text-base font-medium font-inter rounded-lg border border-surface-secondary-default transition-all duration-300 hover:bg-surface-secondary-hover" + className="w-full max-w-[15rem] px-4 py-3 text-content-title text-base font-medium font-inter rounded-lg border border-surface-secondary-default transition-all duration-300 hover:bg-surface-secondary-hover" > {t("sign")} @@ -183,14 +183,16 @@ export default function LoginPage() { onClick={() => handleConnectWallet(connector)} variant="primary" size="lg" - className="w-full max-w-[15rem] px-4 py-3 text-content-title text-base font-medium font-inter rounded-lg border border-surface-secondary-default transition-all duration-300 hover:bg-surface-secondary-hover" + className="w-full max-w-[15rem] px-4 py-3 text-content-title text-base font-medium font-inter rounded-lg border border-surface-secondary-default transition-all duration-300 hover:bg-surface-secondary-hover" >
- Connect{" "} - {connector.id === "argentX" - ? "Argent X" - : connector.name} + {t("connect_wallet", { + walletName: + connector.id === "argentX" + ? t("argent_x") + : connector.name, + })}
@@ -200,12 +202,6 @@ export default function LoginPage() { )}
- {/* - Sell My Coffee - */} diff --git a/apps/web/src/app/product/[id]/page.tsx b/apps/web/src/app/product/[id]/page.tsx index 41db457..908c18f 100644 --- a/apps/web/src/app/product/[id]/page.tsx +++ b/apps/web/src/app/product/[id]/page.tsx @@ -3,10 +3,12 @@ import SkeletonLoader from "@repo/ui/skeleton"; import { useParams } from "next/navigation"; import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import ProductDetails from "~/app/_components/features/ProductDetails"; interface Product { id: number; + tokenId: number; image: string; name: string; region: string; @@ -20,22 +22,25 @@ interface Product { } interface ApiResponse { - nftMetadata: string; + id: number; + tokenId: number; + nftMetadata: NftMetadata; name: string; - region: string; - farmName: string; - strength: string; bagsAvailable: number; price: number; - process?: string; + //process?: string; } interface NftMetadata { imageUrl: string; description: string; + region: string; + farmName: string; + strength: string; } function ProductPage() { + const { t } = useTranslation(); const params = useParams(); const productId = typeof params.id === "string" ? params.id : params.id?.[0]; const [product, setProduct] = useState(null); @@ -56,20 +61,24 @@ function ProductPage() { } const data = (await response.json()) as ApiResponse; - const parsedMetadata = JSON.parse(data.nftMetadata) as NftMetadata; + const parsedMetadata: NftMetadata = + typeof data.nftMetadata === "string" + ? (JSON.parse(data.nftMetadata) as NftMetadata) + : data.nftMetadata; const product: Product = { id: Number(id), + tokenId: data.tokenId, image: parsedMetadata.imageUrl, name: data.name, - region: data.region, - farmName: data.farmName, - roastLevel: data.strength, + region: parsedMetadata.region, + farmName: parsedMetadata.farmName, + roastLevel: parsedMetadata.strength, bagsAvailable: data.bagsAvailable ?? 10, price: data.price, description: parsedMetadata.description, type: "Buyer", - process: data.process ?? "Natural", + process: "Natural", }; setProduct(product); @@ -94,7 +103,7 @@ function ProductPage() { if (!product) { return (
-

Product not found

+

{t("product_not_found")}

); } diff --git a/apps/web/src/app/shopping-cart/page.tsx b/apps/web/src/app/shopping-cart/page.tsx index 5c498ce..6391cea 100644 --- a/apps/web/src/app/shopping-cart/page.tsx +++ b/apps/web/src/app/shopping-cart/page.tsx @@ -1,12 +1,23 @@ "use client"; import { ArrowLeftIcon, TrashIcon } from "@heroicons/react/24/outline"; +import { useAccount } from "@starknet-react/core"; +import { useProvider } from "@starknet-react/core"; import { useAtom, useAtomValue } from "jotai"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { cartItemsAtom, removeItemAtom } from "~/store/cartAtom"; import type { CartItem } from "~/store/cartAtom"; +import { + ContractsError, + ContractsInterface, + useCofiCollectionContract, + useMarketplaceContract, + useStarkContract, +} from "../../services/contractsInterface"; +//import { api } from "~/trpc/server"; interface DeleteModalProps { isOpen: boolean; @@ -19,6 +30,7 @@ function DeleteConfirmationModal({ onConfirm, onCancel, }: DeleteModalProps) { + const { t } = useTranslation(); if (!isOpen) return null; return ( @@ -26,7 +38,7 @@ function DeleteConfirmationModal({

- Do you want to remove? + {t("remove_confirmation_title")}

@@ -57,7 +69,15 @@ export default function ShoppingCart() { const items = useAtomValue(cartItemsAtom); const [, removeItem] = useAtom(removeItemAtom); const [itemToDelete, setItemToDelete] = useState(null); - + const { provider } = useProvider(); + const contract = new ContractsInterface( + useAccount(), + useCofiCollectionContract(), + useMarketplaceContract(), + useStarkContract(), + provider, + ); + const { t } = useTranslation(); const handleRemove = (item: CartItem) => { setItemToDelete(item); }; @@ -73,6 +93,28 @@ export default function ShoppingCart() { setItemToDelete(null); }; + const handleBuy = async () => { + const token_ids = items.map((item) => item.tokenId); + const token_amounts = items.map((item) => item.quantity); + console.log("buying items", token_ids, token_amounts, totalPrice); + try { + const tx_hash = await contract.buy_product( + token_ids, + token_amounts, + totalPrice, + ); + alert(`Items bought successfully tx hash: ${tx_hash}`); + for (const item of items) { + removeItem(item.id); + } + } catch (error) { + if (error instanceof ContractsError) { + alert(error.message); + } + console.error("Error buying items:", error); + } + }; + const totalPrice = items.reduce( (total, item) => total + item.price * item.quantity, 0, @@ -93,13 +135,13 @@ export default function ShoppingCart() { > -

My cart

+

{t("shopping_cart_title")}

{items.length === 0 ? (
- Your cart is empty + {t("cart_empty_message")}
) : ( items.map((item) => ( @@ -116,9 +158,9 @@ export default function ShoppingCart() { className="rounded-lg object-cover bg-gray-100" />
-

{item.name}

+

{t(item.name)}

- quantity: {item.quantity} + {t("quantity_label")}: {item.quantity}

@@ -144,7 +186,9 @@ export default function ShoppingCart() { <>
- TOTAL + + {t("total_label")} + {totalPrice} USD @@ -153,10 +197,13 @@ export default function ShoppingCart() {
diff --git a/apps/web/src/app/user-profile/page.tsx b/apps/web/src/app/user-profile/page.tsx index 0c1d2d6..9edc9a8 100644 --- a/apps/web/src/app/user-profile/page.tsx +++ b/apps/web/src/app/user-profile/page.tsx @@ -4,30 +4,23 @@ import PageHeader from "@repo/ui/pageHeader"; import { useAccount, useDisconnect } from "@starknet-react/core"; import { useRouter } from "next/navigation"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { ProfileCard } from "~/app/_components/features/ProfileCard"; import { ProfileOptions } from "~/app/_components/features/ProfileOptions"; import Main from "~/app/_components/layout/Main"; - -type Badge = "Lover" | "Contributor" | "Producer"; - -type UserProfile = { - name: string; - country: string; - memberSince: number; - thumbnailUrl: string; - badges: Badge[]; -}; +import type { UserProfileType } from "~/types"; export default function UserProfile() { + const { t } = useTranslation(); const { address } = useAccount(); const { disconnect } = useDisconnect(); - const [user] = useState({ + const [user] = useState({ name: "John Doe", - country: "United States", + country: "united_states", memberSince: 2020, thumbnailUrl: "/images/user-profile/avatar.svg", - badges: ["Lover"], + badges: ["lover", "contributor"], }); const router = useRouter(); @@ -36,14 +29,19 @@ export default function UserProfile() {
router.back()} showBlockie={false} /> - +
diff --git a/apps/web/src/app/user/collectibles/page.tsx b/apps/web/src/app/user/collectibles/page.tsx index b99698f..0003023 100644 --- a/apps/web/src/app/user/collectibles/page.tsx +++ b/apps/web/src/app/user/collectibles/page.tsx @@ -1,39 +1,44 @@ +"use client"; + import NFTCard from "@repo/ui/nftCard"; +import { useTranslation } from "react-i18next"; import { ProfileOptionLayout } from "~/app/_components/features/ProfileOptionLayout"; // TODO: Load collectibles from database and/or blockchain const collectibles = [ { id: 1, - title: "CafΓ© de Especialidad 1", - description: "DescripciΓ³n del CafΓ© de Especialidad 1.", + title: "collectible_title_1", + description: "collectible_description_1", imageUrl: "/images/cafe1.webp", - imageAlt: "Paquete de CafΓ© de Especialidad 1", + imageAlt: "collectible_image_alt_1", }, { id: 2, - title: "CafΓ© de Especialidad 2", - description: "DescripciΓ³n del CafΓ© de Especialidad 2.", + title: "collectible_title_2", + description: "collectible_description_2", imageUrl: "/images/cafe2.webp", - imageAlt: "Paquete de CafΓ© de Especialidad 2", + imageAlt: "collectible_image_alt_2", }, { id: 3, - title: "CafΓ© de Especialidad 3", - description: "DescripciΓ³n del CafΓ© de Especialidad 3.", + title: "collectible_title_3", + description: "collectible_description_3", imageUrl: "/images/cafe3.webp", - imageAlt: "Paquete de CafΓ© de Especialidad 3", + imageAlt: "collectible_image_alt_3", }, ]; export default function Collectibles() { + const { t } = useTranslation(); + return ( - +
{collectibles.map((collectible) => ( ))} diff --git a/apps/web/src/app/user/edit-profile/farm-profile/page.tsx b/apps/web/src/app/user/edit-profile/farm-profile/page.tsx index c8e245b..65edf66 100644 --- a/apps/web/src/app/user/edit-profile/farm-profile/page.tsx +++ b/apps/web/src/app/user/edit-profile/farm-profile/page.tsx @@ -7,30 +7,37 @@ import InputField from "@repo/ui/form/inputField"; import Image from "next/image"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; import { z } from "zod"; import { ProfileOptionLayout } from "~/app/_components/features/ProfileOptionLayout"; import { api } from "~/trpc/react"; const schema = z.object({ - farmName: z.string().min(1, "Farm name is required"), - region: z.string().min(1, "Region is required"), - altitude: z.number().min(0, "Altitude must be a positive number"), + farmName: z.string().min(1, "farm_name_required"), + region: z.string().min(1, "region_required"), + altitude: z.union([ + z.number().min(0, "altitude_positive"), + z + .string() + .transform((val) => Number(val)) + .refine((val) => !Number.isNaN(val) && val >= 0, "Not a valid number"), + ]), coordinates: z .string() - .regex(/^-?\d+(\.\d+)?,\s*-?\d+(\.\d+)?$/, "Invalid coordinates format"), - website: z.string().url("Invalid URL").optional().or(z.literal("")), + .regex(/^-?\d+(\.\d+)?,\s*-?\d+(\.\d+)?$/, "coordinates_invalid"), + website: z.string().url("url_invalid").optional().or(z.literal("")), }); type FormData = z.infer; function EditMyFarmProfile() { const utils = api.useUtils(); + const { t } = useTranslation(); const [image, setImage] = useState(null); const userId = 1; // Assume you have the logic to get the userId const { data: userFarm, isLoading } = api.user.getUserFarm.useQuery({ - // TODO: get user id from session or prop userId, }); @@ -44,12 +51,11 @@ function EditMyFarmProfile() { onSuccess: () => { void utils.user.getUser.invalidate({ userId: userId.toString() }); void utils.user.getUserFarm.invalidate({ userId }); - // TODO: display notification - alert("Farm profile updated"); + alert(t("farm_profile_updated")); }, onError: (error) => { console.error("Error updating farm profile:", error); - alert("An error occurred while updating the farm profile"); + alert(t("error_updating_farm")); }, }); @@ -66,14 +72,13 @@ function EditMyFarmProfile() { const onSubmit = (data: FormData) => { if (!userFarm) { - // TODO: Display error in UI - alert("User farm not found. Please contact support."); + alert(t("user_farm_not_found")); return; } updateFarm({ farmId: userFarm.id, - altitude: data.altitude, + altitude: Number(data.altitude), name: data.farmName, region: data.region, coordinates: data.coordinates, @@ -83,18 +88,17 @@ function EditMyFarmProfile() { }; const handleImageUpload = () => { - // TODO: Implement image upload logic - alert("Implement image upload logic"); + alert(t("implement_image_upload")); setImage(null); }; return ( {isLoading ? ( -
Loading...
+
{t("loading")}
) : ( <>
@@ -102,14 +106,14 @@ function EditMyFarmProfile() { {image ? ( Farm Profile ) : (
- No Image + {t("no_image")}
)}
@@ -118,13 +122,13 @@ function EditMyFarmProfile() { onClick={handleImageUpload} > - Choose photo + {t("choose_photo")}
{ void register("farmName").onChange({ target: { value } }); @@ -133,7 +137,7 @@ function EditMyFarmProfile() { className="mb-4" /> { void register("region").onChange({ target: { value } }); @@ -142,7 +146,7 @@ function EditMyFarmProfile() { className="mb-4" /> { void register("altitude").onChange({ @@ -152,7 +156,7 @@ function EditMyFarmProfile() { control={control} /> { void register("coordinates").onChange({ target: { value } }); @@ -160,7 +164,7 @@ function EditMyFarmProfile() { control={control} /> { void register("website").onChange({ target: { value } }); @@ -168,7 +172,7 @@ function EditMyFarmProfile() { control={control} /> diff --git a/apps/web/src/app/user/edit-profile/my-profile/page.tsx b/apps/web/src/app/user/edit-profile/my-profile/page.tsx index 55949ef..fc855ab 100644 --- a/apps/web/src/app/user/edit-profile/my-profile/page.tsx +++ b/apps/web/src/app/user/edit-profile/my-profile/page.tsx @@ -1,146 +1,101 @@ "use client"; -import { CameraIcon } from "@heroicons/react/24/outline"; import { zodResolver } from "@hookform/resolvers/zod"; import Button from "@repo/ui/button"; import InputField from "@repo/ui/form/inputField"; -import Image from "next/image"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; import { z } from "zod"; import { ProfileOptionLayout } from "~/app/_components/features/ProfileOptionLayout"; import { api } from "~/trpc/react"; const schema = z.object({ - fullName: z.string().min(1, "Full name is required"), - email: z.string().email("Invalid email").optional(), - physicalAddress: z.string().min(1, "Address is required"), + fullName: z.string().min(1, "full_name_required"), + email: z.string().email("invalid_email").optional(), + physicalAddress: z.string().min(1, "address_required"), }); type FormData = z.infer; function EditMyProfile() { const utils = api.useUtils(); - const [image, setImage] = useState(null); + const { t } = useTranslation(); - const userId = "1"; // Assume you have the logic to get the userId + const userId = "cm2wbxug00000kkm7tvhlujf2"; - const { data: user, isLoading } = api.user.getUser.useQuery({ - // TODO: get user id from session or prop - userId, + const { data: user, isLoading } = api.user.getUser.useQuery({ userId }); + + const { register, handleSubmit, control, reset } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + fullName: "", + email: "", + physicalAddress: "", + }, }); useEffect(() => { - if (user?.image) { - setImage(user.image); + if (user) { + reset({ + fullName: user.name ?? "", + email: user.email ?? "", + physicalAddress: user.physicalAddress ?? "", + }); } - }, [user]); + }, [user, reset]); const { mutate: updateProfile } = api.user.updateUserProfile.useMutation({ onSuccess: async () => { try { await utils.user.getUser.invalidate({ userId }); - // TODO: display notification - alert("Profile updated"); + alert(t("profile_updated")); } catch (error) { - console.error("Failed to invalidate user data:", error); + console.error(t("invalidate_user_data_failed"), error); } }, }); - const { register, handleSubmit, control } = useForm({ - resolver: zodResolver(schema), - defaultValues: { - fullName: user?.name ?? "", - email: user?.email ?? "", - physicalAddress: user?.physicalAddress ?? "", - }, - }); - const onSubmit = (data: FormData) => { void updateProfile({ - userId: userId, + userId, name: data.fullName, + email: data.email, physicalAddress: data.physicalAddress, - image: image ?? undefined, }); }; - const handleImageUpload = () => { - // TODO: Implement image upload logic - void (async () => { - alert("Implement image upload logic"); - setImage(null); - })(); - }; - return ( - + {isLoading ? ( -
Loading...
+
{t("loading")}
) : ( <> -
-
- {image ? ( - Profile - ) : ( -
- No Image -
- )} -
- -
-
{ - void register("fullName").onChange({ target: { value } }); - }} + label={t("full_name")} + name="fullName" control={control} className="mb-4" /> { - void register("email").onChange({ target: { value } }); - }} + label={t("email")} + name="email" control={control} className="mb-4" - disabled - // TODO: update input style (set #F8FAFC as bg color and #788788 as text color) - // TODO: Add support to add input icon (add email icon at the beginning of the input) inputClassName="cursor-not-allowed" /> { - void register("physicalAddress").onChange({ - target: { value }, - }); - }} + label={t("physical_address")} + name="physicalAddress" control={control} className="mb-4" /> diff --git a/apps/web/src/app/user/edit-profile/page.tsx b/apps/web/src/app/user/edit-profile/page.tsx index 431b49d..ceeb487 100644 --- a/apps/web/src/app/user/edit-profile/page.tsx +++ b/apps/web/src/app/user/edit-profile/page.tsx @@ -1,37 +1,34 @@ +"use client"; + import { ChevronRightIcon } from "@heroicons/react/24/outline"; import Image from "next/image"; import Link from "next/link"; +import { useTranslation } from "react-i18next"; import { ProfileOptionLayout } from "~/app/_components/features/ProfileOptionLayout"; - -type EditProfileOption = { - imgUrl: string; - label: string; - href: string; - customClass?: string; - iconColor?: string; -}; +import type { EditProfileOption } from "~/types"; const editProfileOptions: EditProfileOption[] = [ { imgUrl: "/images/user-profile/avatar.svg", - label: "Edit my profile", + labelKey: "edit_my_profile", href: "/user/edit-profile/my-profile", }, { imgUrl: "/images/user-profile/farm-avatar.svg", - label: "Edit my farm profile", + labelKey: "edit_my_farm_profile", href: "/user/edit-profile/farm-profile", }, ]; export default function EditProfile() { + const { t } = useTranslation(); const lastOptionIndex = editProfileOptions.length - 1; return ( - +
{editProfileOptions.map((option, index) => ( {option.label} - {option.label} + {t(option.labelKey)}
(null); - const items = useAtomValue(cartItemsAtom); const [, addItem] = useAtom(addItemAtom); const handleAddToCart = (productId: number) => { addItem({ id: productId.toString(), + tokenId: productId, name: "Product Name", quantity: 1, price: 10.0, @@ -44,17 +47,17 @@ export default function Favorites() { }; return ( - +
{userFavoriteProducts.map(({ id, imageUrl }) => (
handleAddToCart(id)} isAddingToShoppingCart={addedProduct === id} /> diff --git a/apps/web/src/app/user/my-claims/[id]/page.tsx b/apps/web/src/app/user/my-claims/[id]/page.tsx index 95ae1bf..12f45ba 100644 --- a/apps/web/src/app/user/my-claims/[id]/page.tsx +++ b/apps/web/src/app/user/my-claims/[id]/page.tsx @@ -2,24 +2,16 @@ import { useParams } from "next/navigation"; import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import ProductStatusDetails from "~/app/_components/features/ProductStatusDetails"; import { ProfileOptionLayout } from "~/app/_components/features/ProfileOptionLayout"; - -type SaleDetails = { - productName: string; - status: string; - roast: string; - type: string; - quantity: string; - delivery: string; - totalPrice: string; - address?: string; -}; +import type { SaleDetailsType } from "~/types"; export default function MySaleDetails() { const { id: saleId } = useParams(); + const { t } = useTranslation(); - const [saleDetails, setSaleDetails] = useState(null); + const [saleDetails, setSaleDetails] = useState(null); // TODO: Fetch user role based on user id or from session/context/token const [isProducer, setIsProducer] = useState(false); @@ -27,21 +19,21 @@ export default function MySaleDetails() { // TODO: Fetch sale details based on saleId if (saleId) { setSaleDetails({ - productName: "Product Name", - status: "Delivered", - roast: "strong", - type: "grounded", - quantity: "5 bags", - delivery: "Meetup", - totalPrice: "50 USD", + productName: t("product_name"), + status: t("order_status.delivered"), + roast: t("roast.strong"), + type: t("coffee_type.grounded"), + quantity: t("quantity_with_unit", { count: 5, unit: t("bags") }), + delivery: t("delivery_method.meetup"), + totalPrice: t("price_with_currency", { price: 50, currency: "USD" }), }); } // TODO: Fetch user role based on user id or from session/context/token setIsProducer(true); - }, [saleId]); + }, [saleId, t]); - const updateSaleDetails = (productDetails: SaleDetails) => { + const updateSaleDetails = (productDetails: SaleDetailsType) => { // TODO: Implement logic to update sale details setSaleDetails(productDetails); }; diff --git a/apps/web/src/app/user/my-claims/page.tsx b/apps/web/src/app/user/my-claims/page.tsx index b4a6ab8..bdbaf5b 100644 --- a/apps/web/src/app/user/my-claims/page.tsx +++ b/apps/web/src/app/user/my-claims/page.tsx @@ -6,71 +6,63 @@ import { MagnifyingGlassIcon, } from "@heroicons/react/24/solid"; import Button from "@repo/ui/button"; -import { useRouter } from "next/navigation"; +import { useAccount, useProvider } from "@starknet-react/core"; import { useEffect, useState } from "react"; import OrderListItem from "~/app/_components/features/OrderListItem"; import OrderListPriceItem from "~/app/_components/features/OrderListPriceItem"; import { ProfileOptionLayout } from "~/app/_components/features/ProfileOptionLayout"; +import { + ContractsInterface, + useCofiCollectionContract, + useMarketplaceContract, + useStarkContract, +} from "~/services/contractsInterface"; +import { DeliveryMethod, SalesStatus } from "~/types"; -const SalesStatusEnum = { - Paid: "Paid", - Prepared: "Prepared", - Shipped: "Shipped", - Delivered: "Delivered", -} as const; - -type SalesStatus = (typeof SalesStatusEnum)[keyof typeof SalesStatusEnum]; - -const DeliveryMethodEnum = { - Address: "Address", - Meetup: "Meetup", -} as const; - -type DeliveryMethod = - (typeof DeliveryMethodEnum)[keyof typeof DeliveryMethodEnum]; +import { useTranslation } from "react-i18next"; const mockedOrders = [ { - date: "October 18", + date: "october_18", items: [ { id: "1", - productName: "productName", + productName: "product_name_1", buyerName: "buyer1_fullname", - status: SalesStatusEnum.Delivered as SalesStatus, - delivery: DeliveryMethodEnum.Address as DeliveryMethod, + status: SalesStatus.Delivered, + delivery: DeliveryMethod.Address, price: 30, claimed: false, }, { id: "2", - productName: "productName2", + productName: "product_name_2", buyerName: "buyer2_fullname", - status: SalesStatusEnum.Delivered as SalesStatus, - delivery: DeliveryMethodEnum.Meetup as DeliveryMethod, + status: SalesStatus.Delivered, + delivery: DeliveryMethod.Meetup, price: 30, claimed: false, }, ], }, { - date: "September 20", + date: "september_20", items: [ { id: "3", - productName: "productName3", + productName: "product_name_3", buyerName: "buyer1_fullname", - status: SalesStatusEnum.Delivered as SalesStatus, - delivery: DeliveryMethodEnum.Address as DeliveryMethod, + status: SalesStatus.Delivered, + delivery: DeliveryMethod.Address, price: 30, claimed: true, }, { id: "4", - productName: "productName4", + productName: "product_name_4", buyerName: "buyer2_fullname", - status: SalesStatusEnum.Delivered as SalesStatus, - delivery: DeliveryMethodEnum.Meetup as DeliveryMethod, + status: SalesStatus.Delivered, + delivery: DeliveryMethod.Meetup, price: 30, claimed: true, }, @@ -83,56 +75,73 @@ export default function MyClaims() { const [ClaimedOrders, setClaimedOrders] = useState(mockedOrders); const [searchTerm, setSearchTerm] = useState(""); const [MoneyToClaim, setMoneyToClaim] = useState(0); - const [isFiltersModalOpen, setIsFiltersModalOpen] = useState(false); - - const router = useRouter(); + const [checked, setChecked] = useState(false); + const [, setIsFiltersModalOpen] = useState(false); + const { t } = useTranslation(); + + const { provider } = useProvider(); + const contracts = new ContractsInterface( + useAccount(), + useCofiCollectionContract(), + useMarketplaceContract(), + useStarkContract(), + provider, + ); useEffect(() => { - const unclaimedOrders = mockedOrders - .map((orderGroup) => ({ - ...orderGroup, - items: orderGroup.items.filter((item) => !item.claimed), - })) - .filter((orderGroup) => orderGroup.items.length > 0); - - setOrdersToClaim(unclaimedOrders); - - const totalMoneyToClaim = unclaimedOrders.reduce((total, orderGroup) => { - return ( - total + - orderGroup.items.reduce( - (groupTotal, item) => groupTotal + item.price, - 0, - ) - ); - }, 0); - - setMoneyToClaim(totalMoneyToClaim); - - const claimedOrders = mockedOrders - .map((orderGroup) => ({ - ...orderGroup, - items: orderGroup.items.filter((item) => item.claimed), - })) - .filter((orderGroup) => orderGroup.items.length > 0); - - setClaimedOrders(claimedOrders); + const fetchData = () => { + const unclaimedOrders = mockedOrders + .map((orderGroup) => ({ + ...orderGroup, + items: orderGroup.items.filter((item) => !item.claimed), + })) + .filter((orderGroup) => orderGroup.items.length > 0); + + setOrdersToClaim(unclaimedOrders); + + const totalMoneyToClaim = 0; + + setMoneyToClaim(totalMoneyToClaim); + + const claimedOrders = mockedOrders + .map((orderGroup) => ({ + ...orderGroup, + items: orderGroup.items.filter((item) => item.claimed), + })) + .filter((orderGroup) => orderGroup.items.length > 0); + + setClaimedOrders(claimedOrders); + }; + + fetchData(); }, []); const openFiltersModal = () => { setIsFiltersModalOpen(true); }; + const handleClaim = async () => { + if (!checked) { + const total = await contracts.get_claim_balance(); + setMoneyToClaim(Number(total)); + setChecked(true); + return; + } + console.log("claiming"); + const tx = await contracts.claim(); + alert(`Claimed success with tx: ${tx}`); + }; + const handleItemClick = (id: string) => { window.location.href = `/user/my-claims/${id}`; }; return ( - +

- Total balance receivable + {t("total_balance_receivable")}

@@ -150,22 +159,21 @@ export default function MyClaims() { {OrdersToClaim.map((orderGroup, index) => (

- {orderGroup.date} + {t(orderGroup.date)}

{orderGroup.items.map((order, orderIndex) => ( - <> +
handleItemClick(order.id)} /> {orderIndex < orderGroup.items.length - 1 && (
)} - +
))}
@@ -174,14 +182,16 @@ export default function MyClaims() {

- History + {t("history")}

@@ -190,7 +200,7 @@ export default function MyClaims() { setSearchTerm(e.target.value)} @@ -209,16 +219,16 @@ export default function MyClaims() { {ClaimedOrders.map((orderGroup, index) => (

- {orderGroup.date} + {t(orderGroup.date)}

{orderGroup.items.map((order, orderIndex) => ( <> handleItemClick(order.id)} /> {orderIndex < orderGroup.items.length - 1 && ( diff --git a/apps/web/src/app/user/my-coffee/page.tsx b/apps/web/src/app/user/my-coffee/page.tsx index df2b28b..28d8d7b 100644 --- a/apps/web/src/app/user/my-coffee/page.tsx +++ b/apps/web/src/app/user/my-coffee/page.tsx @@ -3,16 +3,18 @@ import Button from "@repo/ui/button"; import Image from "next/image"; import { useRouter } from "next/navigation"; +import { useTranslation } from "react-i18next"; import { ProfileOptionLayout } from "~/app/_components/features/ProfileOptionLayout"; export default function MyCoffee() { + const { t } = useTranslation(); const router = useRouter(); const handleOfferMyCoffee = () => { router.push("/user/register-coffee"); }; return ( - +

- Would you like to sell your coffee? + {t("sell_your_coffee")}

diff --git a/apps/web/src/app/user/my-orders/[id]/page.tsx b/apps/web/src/app/user/my-orders/[id]/page.tsx index 8e4aacc..b9c0ed9 100644 --- a/apps/web/src/app/user/my-orders/[id]/page.tsx +++ b/apps/web/src/app/user/my-orders/[id]/page.tsx @@ -2,24 +2,18 @@ import { useParams } from "next/navigation"; import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; import ProductStatusDetails from "~/app/_components/features/ProductStatusDetails"; import { ProfileOptionLayout } from "~/app/_components/features/ProfileOptionLayout"; - -type OrderDetails = { - productName: string; - status: string; - roast: string; - type: string; - quantity: string; - delivery: string; - totalPrice: string; - address?: string; -}; +import type { OrderDetailsType } from "~/types"; export default function OrderDetails() { + const { t } = useTranslation(); const { id: orderId } = useParams(); - const [orderDetails, setOrderDetails] = useState(null); + const [orderDetails, setOrderDetails] = useState( + null, + ); // TODO: Fetch user role based on user id or from session/context/token const [isProducer, setIsProducer] = useState(false); @@ -27,22 +21,22 @@ export default function OrderDetails() { // TODO: Fetch order details based on orderId if (orderId) { setOrderDetails({ - productName: "Sample Product", - status: "Paid", - roast: "strong", - type: "grounded", - quantity: "5 bags", - delivery: "Delivery", + productName: t("sample_product"), + status: t("paid"), + roast: t("strong"), + type: t("grounded"), + quantity: `5 ${t("bags")}`, + delivery: t("delivery"), address: "Av Portugal 375, ap 410 SΓ£o Paulo/SP CEP 66010-100", - totalPrice: "50 USD", + totalPrice: `50 ${t("usd")}`, }); } // TODO: Fetch user role based on user id or from session/context/token setIsProducer(true); - }, [orderId]); + }, [orderId, t]); - const updateProductDetails = (productDetails: OrderDetails) => { + const updateProductDetails = (productDetails: OrderDetailsType) => { // TODO: Implement logic to update order details setOrderDetails(productDetails); }; diff --git a/apps/web/src/app/user/my-orders/page.tsx b/apps/web/src/app/user/my-orders/page.tsx index 5ede822..6fed727 100644 --- a/apps/web/src/app/user/my-orders/page.tsx +++ b/apps/web/src/app/user/my-orders/page.tsx @@ -5,151 +5,93 @@ import { zodResolver } from "@hookform/resolvers/zod"; import Button from "@repo/ui/button"; import CheckBox from "@repo/ui/form/checkBox"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; -import { z } from "zod"; +import { useTranslation } from "react-i18next"; import OrderListItem from "~/app/_components/features/OrderListItem"; import { ProfileOptionLayout } from "~/app/_components/features/ProfileOptionLayout"; import BottomModal from "~/app/_components/ui/BottomModal"; - -const SalesStatusEnum = { - Paid: "Paid", - Prepared: "Prepared", - Shipped: "Shipped", - Delivered: "Delivered", -} as const; - -type SalesStatus = (typeof SalesStatusEnum)[keyof typeof SalesStatusEnum]; - -const filtersSchema = z.object({ - statusPaid: z.boolean().optional(), - statusPrepared: z.boolean().optional(), - statusShipped: z.boolean().optional(), - statusDelivered: z.boolean().optional(), -}); - -type FormValues = z.infer; +import { useOrderFiltering } from "~/hooks/user/useOrderFiltering"; +import { type FormValues, SalesStatus, filtersSchema } from "~/types"; const mockedOrders = [ { - date: "October 18", + date: "october_18", items: [ { id: "1", - productName: "Edit profile", + productName: "product_name_1", sellerName: "seller1_fullname", - status: SalesStatusEnum.Paid as SalesStatus, + status: SalesStatus.Paid, }, { id: "2", - productName: "My Orders", + productName: "product_name_2", sellerName: "seller2_fullname", - status: SalesStatusEnum.Paid as SalesStatus, + status: SalesStatus.Paid, }, ], }, { - date: "September 20", + date: "september_20", items: [ { id: "3", - productName: "productName", + productName: "product_name_3", sellerName: "seller1_fullname", - status: SalesStatusEnum.Delivered as SalesStatus, + status: SalesStatus.Delivered, }, { id: "4", - productName: "productName", + productName: "product_name_4", sellerName: "seller2_fullname", - status: SalesStatusEnum.Delivered as SalesStatus, + status: SalesStatus.Delivered, }, ], }, ]; +const filtersDefaults = { + statusPaid: false, + statusPrepared: false, + statusShipped: false, + statusDelivered: false, +}; + export default function MyOrders() { - const [searchTerm, setSearchTerm] = useState(""); - const [isFiltersModalOpen, setIsFiltersModalOpen] = useState(false); - const [filteredOrders, setFilteredOrders] = useState(mockedOrders); - const [activeFilters, setActiveFilters] = useState({}); + const { t } = useTranslation(); + const { + searchTerm, + setSearchTerm, + isFiltersModalOpen, + openFiltersModal, + closeFiltersModal, + filteredOrders, + applyFilters, + } = useOrderFiltering({ + orders: mockedOrders, + searchKey: "sellerName", + filters: filtersDefaults, + }); const router = useRouter(); - const openFiltersModal = () => { - setIsFiltersModalOpen(true); - }; - - const closeFiltersModal = () => { - setIsFiltersModalOpen(false); - }; - const { control, handleSubmit } = useForm({ resolver: zodResolver(filtersSchema), - defaultValues: { - statusPaid: false, - statusPrepared: false, - statusShipped: false, - statusDelivered: false, - }, + defaultValues: filtersDefaults, }); - const onSubmit = (data: FormValues) => { - console.log(data); - setActiveFilters(data); - closeFiltersModal(); - }; - const handleItemClick = (id: string) => { router.push(`/user/my-orders/${id}`); }; - useEffect(() => { - const newFilteredOrders = mockedOrders - .map((orderGroup) => ({ - ...orderGroup, - items: orderGroup.items.filter((item) => { - const matchesSearch = searchTerm - ? item.sellerName.toLowerCase().includes(searchTerm.toLowerCase()) - : true; - - const activeStatusFilters = [ - activeFilters.statusPaid, - activeFilters.statusPrepared, - activeFilters.statusShipped, - activeFilters.statusDelivered, - ]; - - const matchesStatus = activeStatusFilters.some(Boolean) - ? (activeFilters.statusPaid && - item.status === SalesStatusEnum.Paid) ?? - (activeFilters.statusPrepared && - item.status === SalesStatusEnum.Prepared) ?? - (activeFilters.statusShipped && - item.status === SalesStatusEnum.Shipped) ?? - (activeFilters.statusDelivered && - item.status === SalesStatusEnum.Delivered) - : true; - - return matchesSearch && matchesStatus; - }), - })) - .filter((orderGroup) => orderGroup.items.length > 0); - - if (!searchTerm && !Object.values(activeFilters).some(Boolean)) { - setFilteredOrders(mockedOrders); - } else { - setFilteredOrders(newFilteredOrders); - } - }, [searchTerm, activeFilters]); - return ( - +
setSearchTerm(e.target.value)} @@ -167,16 +109,16 @@ export default function MyOrders() { {filteredOrders.map((orderGroup, index) => (

- {orderGroup.date} + {t(orderGroup.date)}

{orderGroup.items.map((order, orderIndex) => ( <> handleItemClick(order.id)} /> {orderIndex < orderGroup.items.length - 1 && ( @@ -190,39 +132,39 @@ export default function MyOrders() {
-
+

- Status + {t("status")}

<>


diff --git a/apps/web/src/app/user/my-sales/[id]/page.tsx b/apps/web/src/app/user/my-sales/[id]/page.tsx index 4ce3807..9c0b29b 100644 --- a/apps/web/src/app/user/my-sales/[id]/page.tsx +++ b/apps/web/src/app/user/my-sales/[id]/page.tsx @@ -4,22 +4,12 @@ import { useParams } from "next/navigation"; import React, { useEffect, useState } from "react"; import ProductStatusDetails from "~/app/_components/features/ProductStatusDetails"; import { ProfileOptionLayout } from "~/app/_components/features/ProfileOptionLayout"; - -type SaleDetails = { - productName: string; - status: string; - roast: string; - type: string; - quantity: string; - delivery: string; - totalPrice: string; - address?: string; -}; +import type { SaleDetailsType } from "~/types"; export default function SaleDetails() { const { id: saleId } = useParams(); - const [saleDetails, setSaleDetails] = useState(null); + const [saleDetails, setSaleDetails] = useState(null); // TODO: Fetch user role based on user id or from session/context/token const [isProducer, setIsProducer] = useState(false); @@ -41,7 +31,7 @@ export default function SaleDetails() { setIsProducer(true); }, [saleId]); - const updateSaleDetails = (productDetails: SaleDetails) => { + const updateSaleDetails = (productDetails: SaleDetailsType) => { // TODO: Implement logic to update sale details setSaleDetails(productDetails); }; diff --git a/apps/web/src/app/user/my-sales/page.tsx b/apps/web/src/app/user/my-sales/page.tsx index b6bece5..ede4b91 100644 --- a/apps/web/src/app/user/my-sales/page.tsx +++ b/apps/web/src/app/user/my-sales/page.tsx @@ -5,179 +5,105 @@ import { zodResolver } from "@hookform/resolvers/zod"; import Button from "@repo/ui/button"; import CheckBox from "@repo/ui/form/checkBox"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; -import { z } from "zod"; +import { useTranslation } from "react-i18next"; import OrderListItem from "~/app/_components/features/OrderListItem"; import { ProfileOptionLayout } from "~/app/_components/features/ProfileOptionLayout"; import BottomModal from "~/app/_components/ui/BottomModal"; - -const SalesStatusEnum = { - Paid: "Paid", - Prepared: "Prepared", - Shipped: "Shipped", - Delivered: "Delivered", -} as const; - -type SalesStatus = (typeof SalesStatusEnum)[keyof typeof SalesStatusEnum]; - -const DeliveryMethodEnum = { - Address: "Address", - Meetup: "Meetup", -} as const; - -type DeliveryMethod = - (typeof DeliveryMethodEnum)[keyof typeof DeliveryMethodEnum]; - -const filtersSchema = z.object({ - statusPaid: z.boolean().optional(), - statusPrepared: z.boolean().optional(), - statusShipped: z.boolean().optional(), - statusDelivered: z.boolean().optional(), - deliveryAddress: z.boolean().optional(), - deliveryMeetup: z.boolean().optional(), -}); - -type FormValues = z.infer; +import { useOrderFiltering } from "~/hooks/user/useOrderFiltering"; +import { + DeliveryMethod, + type FormValues, + SalesStatus, + filtersSchema, +} from "~/types"; const mockedOrders = [ { - date: "October 18", + date: "october_18", items: [ { id: "1", - productName: "Edit profile", + productName: "product_name_1", buyerName: "buyer1_fullname", - status: SalesStatusEnum.Paid as SalesStatus, - delivery: DeliveryMethodEnum.Address as DeliveryMethod, + status: SalesStatus.Paid, + delivery: DeliveryMethod.Address, }, { id: "2", - productName: "My Orders", + productName: "product_name_2", buyerName: "buyer2_fullname", - status: SalesStatusEnum.Paid as SalesStatus, - delivery: DeliveryMethodEnum.Meetup as DeliveryMethod, + status: SalesStatus.Paid, + delivery: DeliveryMethod.Meetup, }, ], }, { - date: "September 20", + date: "september_20", items: [ { id: "3", - productName: "productName", + productName: "product_name_1", buyerName: "buyer1_fullname", - status: SalesStatusEnum.Delivered as SalesStatus, - delivery: DeliveryMethodEnum.Address as DeliveryMethod, + status: SalesStatus.Delivered, + delivery: DeliveryMethod.Address, }, { id: "4", - productName: "productName", + productName: "product_name_2", buyerName: "buyer2_fullname", - status: SalesStatusEnum.Delivered as SalesStatus, - delivery: DeliveryMethodEnum.Meetup as DeliveryMethod, + status: SalesStatus.Delivered, + delivery: DeliveryMethod.Meetup, }, ], }, ]; +const filtersDefaults = { + statusPaid: false, + statusPrepared: false, + statusShipped: false, + statusDelivered: false, + deliveryAddress: false, + deliveryMeetup: false, +}; + export default function MySales() { - const [searchTerm, setSearchTerm] = useState(""); - const [isFiltersModalOpen, setIsFiltersModalOpen] = useState(false); - const [filteredOrders, setFilteredOrders] = useState(mockedOrders); - const [activeFilters, setActiveFilters] = useState({}); + const { t } = useTranslation(); const router = useRouter(); - const openFiltersModal = () => { - setIsFiltersModalOpen(true); - }; - - const closeFiltersModal = () => { - setIsFiltersModalOpen(false); - }; + const { + searchTerm, + setSearchTerm, + isFiltersModalOpen, + openFiltersModal, + closeFiltersModal, + filteredOrders, + applyFilters, + } = useOrderFiltering({ + orders: mockedOrders, + searchKey: "buyerName", + filters: filtersDefaults, + }); const { control, handleSubmit } = useForm({ resolver: zodResolver(filtersSchema), - defaultValues: { - statusPaid: false, - statusPrepared: false, - statusShipped: false, - statusDelivered: false, - deliveryAddress: false, - deliveryMeetup: false, - }, + defaultValues: filtersDefaults, }); - const onSubmit = (data: FormValues) => { - console.log(data); - setActiveFilters(data); - closeFiltersModal(); - }; - const handleItemClick = (id: string) => { router.push(`/user/my-sales/${id}`); }; - useEffect(() => { - const newFilteredOrders = mockedOrders - .map((orderGroup) => ({ - ...orderGroup, - items: orderGroup.items.filter((item) => { - const matchesSearch = searchTerm - ? item.buyerName.toLowerCase().includes(searchTerm.toLowerCase()) - : true; - - const activeStatusFilters = [ - activeFilters.statusPaid, - activeFilters.statusPrepared, - activeFilters.statusShipped, - activeFilters.statusDelivered, - ]; - - const matchesStatus = !activeStatusFilters.some(Boolean) - ? true - : (activeFilters.statusPaid && - item.status === SalesStatusEnum.Paid) ?? - (activeFilters.statusPrepared && - item.status === SalesStatusEnum.Prepared) ?? - (activeFilters.statusShipped && - item.status === SalesStatusEnum.Shipped) ?? - (activeFilters.statusDelivered && - item.status === SalesStatusEnum.Delivered); - - const activeDeliveryFilters = [ - activeFilters.deliveryAddress, - activeFilters.deliveryMeetup, - ]; - - const matchesDelivery = !activeDeliveryFilters.some(Boolean) - ? true - : (activeFilters.deliveryAddress && - item.delivery === DeliveryMethodEnum.Address) ?? - (activeFilters.deliveryMeetup && - item.delivery === DeliveryMethodEnum.Meetup); - - return matchesSearch && matchesStatus && matchesDelivery; - }), - })) - .filter((orderGroup) => orderGroup.items.length > 0); - - if (!searchTerm && !Object.values(activeFilters).some(Boolean)) { - setFilteredOrders(mockedOrders); - } else { - setFilteredOrders(newFilteredOrders); - } - }, [searchTerm, activeFilters]); - return ( - +
setSearchTerm(e.target.value)} @@ -195,15 +121,15 @@ export default function MySales() { {filteredOrders.map((orderGroup, index) => (

- {orderGroup.date} + {t(orderGroup.date)}

{orderGroup.items.map((order, orderIndex) => ( <> handleItemClick(order.id)} /> @@ -218,57 +144,61 @@ export default function MySales() {
-
+

- Delivery method + {t("delivery_method_label")}

<>

- Status + {t("status")}

<>


diff --git a/apps/web/src/app/user/register-coffee/page.tsx b/apps/web/src/app/user/register-coffee/page.tsx index 1e54989..425104e 100644 --- a/apps/web/src/app/user/register-coffee/page.tsx +++ b/apps/web/src/app/user/register-coffee/page.tsx @@ -8,10 +8,21 @@ import { ClockIcon } from "@heroicons/react/24/solid"; import { zodResolver } from "@hookform/resolvers/zod"; import Button from "@repo/ui/button"; import RadioButton from "@repo/ui/form/radioButton"; +import { useAccount, useProvider } from "@starknet-react/core"; import Image from "next/image"; import { useForm } from "react-hook-form"; +import { useTranslation } from "react-i18next"; import { z } from "zod"; import { ProfileOptionLayout } from "~/app/_components/features/ProfileOptionLayout"; +import { + ContractsError, + ContractsInterface, + useCofiCollectionContract, + useMarketplaceContract, + useStarkContract, +} from "~/services/contractsInterface"; +import { api } from "~/trpc/react"; +import { RoastLevel } from "~/types"; const schema = z.object({ roast: z.string().min(1, "Roast level is required"), @@ -29,17 +40,19 @@ const schema = z.object({ type FormData = z.infer; -const RoastLevel = { - LIGHT: "Light", - MEDIUM: "Medium", - STRONG: "Strong", -} as const; - -type RoastLevelType = (typeof RoastLevel)[keyof typeof RoastLevel]; - export default function RegisterCoffee() { + const { t } = useTranslation(); + const { provider } = useProvider(); + const contracts = new ContractsInterface( + useAccount(), + useCofiCollectionContract(), + useMarketplaceContract(), + useStarkContract(), + provider, + ); + const mutation = api.product.createProduct.useMutation(); const handleImageUpload = () => { - alert("Implement image upload"); + alert(t("implement_image_upload")); }; const { register, handleSubmit, control, getValues, setValue } = @@ -58,10 +71,41 @@ export default function RegisterCoffee() { }; // TODO: Implement coffee registration logic console.log(submissionData); + try { + const token_id = await contracts.register_product( + submissionData.price, + submissionData.bagsAvailable, + ); + await mutation.mutateAsync({ + tokenId: token_id, + name: submissionData.variety, + price: submissionData.price, + description: submissionData.description, + image: submissionData.image ?? "/images/cafe1.webp", + strength: submissionData.roast, + region: "", + farmName: "", + }); + alert("Product registered successfully"); + } catch (error) { + if (error instanceof ContractsError) { + if (error.code === ContractsError.USER_MISSING_ROLE) { + alert("User is not registered as a seller"); + } else if (error.code === ContractsError.USER_NOT_CONNECTED) { + alert("User is disconnected"); + } + } else { + console.log("error registering", error); + alert("An error occurred while registering the product"); + } + } }; return ( - +
@@ -79,34 +123,36 @@ export default function RegisterCoffee() { type="button" > - Choose coffee photo + {t("choose_coffee_photo")}