diff --git a/apps/web/next.config.js b/apps/web/next.config.js index d310e13..c8ad7cc 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -11,6 +11,18 @@ const config = { ...nextI18NextConfig.i18n, localeDetection: false, }, + reactStrictMode: true, + transpilePackages: ["@repo/ui"], + images: { + domains: ["gateway.pinata.cloud"], + }, + webpack: (config) => { + config.resolve.fallback = { + ...config.resolve.fallback, + fs: false, + }; + return config; + }, }; export default config; diff --git a/apps/web/src/app/_components/features/ImageUpload.tsx b/apps/web/src/app/_components/features/ImageUpload.tsx new file mode 100644 index 0000000..c4fbe77 --- /dev/null +++ b/apps/web/src/app/_components/features/ImageUpload.tsx @@ -0,0 +1,107 @@ +import { CameraIcon } from "@heroicons/react/24/outline"; +import Image from "next/image"; +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; + +interface ImageUploadProps { + onImageUploaded: (ipfsHash: string) => void; + className?: string; +} + +interface PinataResponse { + success: boolean; + ipfsHash: string; + error?: string; +} + +export function ImageUpload({ + onImageUploaded, + className = "", +}: ImageUploadProps) { + const { t } = useTranslation(); + const [isUploading, setIsUploading] = useState(false); + const [previewUrl, setPreviewUrl] = useState(null); + const [error, setError] = useState(null); + + const handleFileChange = useCallback( + async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + try { + // Create preview + const objectUrl = URL.createObjectURL(file); + setPreviewUrl(objectUrl); + setIsUploading(true); + setError(null); + + // Create form data + const formData = new FormData(); + formData.append("file", file); + + // Upload to our API endpoint + const response = await fetch("/api/upload", { + method: "POST", + body: formData, + }); + + const data = (await response.json()) as PinataResponse; + + if (!response.ok || !data.success) { + throw new Error(data.error ?? "Failed to upload image"); + } + + onImageUploaded(data.ipfsHash); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Failed to upload image"; + setError(errorMessage); + console.error("Upload error:", error); + } finally { + setIsUploading(false); + } + }, + [onImageUploaded], + ); + + return ( +
+ + + + + {error &&

{error}

} +
+ ); +} diff --git a/apps/web/src/app/_components/features/ProductDetails.tsx b/apps/web/src/app/_components/features/ProductDetails.tsx index 6d3cbaf..f6c8fe0 100644 --- a/apps/web/src/app/_components/features/ProductDetails.tsx +++ b/apps/web/src/app/_components/features/ProductDetails.tsx @@ -2,14 +2,12 @@ import { HeartIcon, ShareIcon } from "@heroicons/react/24/outline"; import { HeartIcon as HeartSolidIcon } from "@heroicons/react/24/solid"; import Button from "@repo/ui/button"; import { DataCard } from "@repo/ui/dataCard"; -import { InfoCard } from "@repo/ui/infoCard"; import PageHeader from "@repo/ui/pageHeader"; import { H1, H2, Text } from "@repo/ui/typography"; import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useState } from "react"; -import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; import { api } from "~/trpc/react"; import { ProducerInfo } from "./ProducerInfo"; diff --git a/apps/web/src/app/_components/features/WalletConnect.tsx b/apps/web/src/app/_components/features/WalletConnect.tsx index 6936236..fdf6faf 100644 --- a/apps/web/src/app/_components/features/WalletConnect.tsx +++ b/apps/web/src/app/_components/features/WalletConnect.tsx @@ -9,8 +9,6 @@ import { useSignTypedData, } from "@starknet-react/core"; import { signIn, signOut } from "next-auth/react"; -import { useSession } from "next-auth/react"; -import Image from "next/image"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; diff --git a/apps/web/src/app/api/upload/route.ts b/apps/web/src/app/api/upload/route.ts new file mode 100644 index 0000000..41c0ba9 --- /dev/null +++ b/apps/web/src/app/api/upload/route.ts @@ -0,0 +1,33 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { uploadToPinata, validateImage } from "../../../utils/pinata"; + +export async function POST(request: NextRequest) { + try { + const formData = await request.formData(); + const file = formData.get("file") as File; + + if (!file) { + return NextResponse.json({ error: "No file provided" }, { status: 400 }); + } + + if (!validateImage(file)) { + return NextResponse.json( + { error: "Invalid file type or size" }, + { status: 400 }, + ); + } + + const ipfsHash = await uploadToPinata(file); + + return NextResponse.json({ + success: true, + ipfsHash, + }); + } catch (error) { + console.error("Upload error:", error); + return NextResponse.json( + { error: "Failed to upload file" }, + { status: 500 }, + ); + } +} 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 eef62c5..cb826b0 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 @@ -27,12 +27,7 @@ function EditMyProfile() { const { data: session } = useSession(); const router = useRouter(); - const { - handleSubmit, - formState: { errors }, - control, - reset, - } = useForm({ + const { handleSubmit, control, reset } = useForm({ resolver: zodResolver(schema), defaultValues: { fullName: "", diff --git a/apps/web/src/app/user/register-coffee/page.tsx b/apps/web/src/app/user/register-coffee/page.tsx index e48ec71..dac3e6e 100644 --- a/apps/web/src/app/user/register-coffee/page.tsx +++ b/apps/web/src/app/user/register-coffee/page.tsx @@ -1,18 +1,19 @@ "use client"; -import { - ArrowPathRoundedSquareIcon, - CameraIcon, -} from "@heroicons/react/24/outline"; -import { ClockIcon } from "@heroicons/react/24/solid"; +import { ArrowPathRoundedSquareIcon } 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 NumericField from "@repo/ui/form/numericField"; import RadioButton from "@repo/ui/form/radioButton"; +import TextAreaField from "@repo/ui/form/textAreaField"; import { useAccount, useProvider } from "@starknet-react/core"; -import Image from "next/image"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { z } from "zod"; +import { ImageUpload } from "~/app/_components/features/ImageUpload"; import { ProfileOptionLayout } from "~/app/_components/features/ProfileOptionLayout"; import { ContractsError, @@ -23,6 +24,7 @@ import { } from "~/services/contractsInterface"; import { api } from "~/trpc/react"; import { RoastLevel } from "~/types"; +import { getStarknetPrice, usdToStrk } from "~/utils/priceConverter"; const schema = z.object({ roast: z.string().min(1, "Roast level is required"), @@ -35,13 +37,14 @@ const schema = z.object({ .min(0, "Coffee score must be a positive number") .max(100, "Coffee score must be at most 100") .optional(), - image: z.string().optional(), + image: z.string().min(1, "Image is required"), }); type FormData = z.infer; export default function RegisterCoffee() { const { t } = useTranslation(); + const router = useRouter(); const { provider } = useProvider(); const contracts = new ContractsInterface( useAccount(), @@ -51,316 +54,196 @@ export default function RegisterCoffee() { provider, ); const mutation = api.product.createProduct.useMutation(); - const handleImageUpload = () => { - alert(t("implement_image_upload")); - }; + const [strkPrice, setStrkPrice] = useState(null); + const [isLoadingPrice, setIsLoadingPrice] = useState(false); + + const fetchStrkPrice = useCallback(async () => { + try { + setIsLoadingPrice(true); + const price = await getStarknetPrice(); + setStrkPrice(price); + } catch (error) { + console.error("Error fetching STRK price:", error); + } finally { + setIsLoadingPrice(false); + } + }, []); + + useEffect(() => { + void fetchStrkPrice(); + // Refresh price every 5 minutes + const interval = setInterval(() => void fetchStrkPrice(), 5 * 60 * 1000); + return () => clearInterval(interval); + }, [fetchStrkPrice]); - const { register, handleSubmit, control, getValues, setValue } = + const { register, handleSubmit, control, setValue, watch } = useForm({ resolver: zodResolver(schema), defaultValues: { roast: RoastLevel.LIGHT, bagsAvailable: 1, + price: "", + coffeeScore: 0, }, }); + const price = watch("price"); + const onSubmit = async (data: FormData) => { - const submissionData = { - ...data, - price: Number.parseFloat(data.price), - }; - // TODO: Implement coffee registration logic - console.log(submissionData); + if (!strkPrice) { + alert(t("error_strk_price_unavailable")); + return; + } + + const usdPrice = Number.parseFloat(data.price); + const strkAmount = usdToStrk(usdPrice, strkPrice); + try { const token_id = await contracts.register_product( - submissionData.price, - submissionData.bagsAvailable, + strkAmount, + data.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, + name: data.variety, + price: strkAmount, + description: data.description, + image: `${process.env.NEXT_PUBLIC_GATEWAY_URL}/ipfs/${data.image}`, + strength: data.roast, region: "", farmName: "", }); - alert("Product registered successfully"); + + alert(t("product_registered_successfully")); + router.push("/user/my-coffee"); } catch (error) { if (error instanceof ContractsError) { if (error.code === ContractsError.USER_MISSING_ROLE) { - alert("User is not registered as a seller"); + alert(t("user_not_registered_as_seller")); } else if (error.code === ContractsError.USER_NOT_CONNECTED) { - alert("User is disconnected"); + alert(t("user_disconnected")); } } else { - console.log("error registering", error); - alert("An error occurred while registering the product"); + console.error("Error registering:", error); + alert(t("error_registering_product")); } } }; + const handlePriceChange = (value: string) => { + // Only allow numbers and one decimal point + const formatted = value.replace(/[^\d.]/g, ""); + const parts = formatted.split("."); + if (parts.length > 2) return; // Don't allow multiple decimal points + if (parts[1] && parts[1].length > 2) return; // Only allow 2 decimal places + setValue("price", formatted); + }; + + const getStrkEquivalent = (usdPrice: string): string => { + if (!strkPrice || !usdPrice) return ""; + const usdAmount = Number.parseFloat(usdPrice); + if (Number.isNaN(usdAmount)) return ""; + return `≈ ${usdToStrk(usdAmount, strkPrice).toFixed(2)} STRK`; + }; + return ( - -
-
-
- Coffee +
+ +
+ { + void setValue("image", hash, { + shouldValidate: true, + }); + }} /> - -
-
- - -
-
- -