diff --git a/.env.example b/.env.example index 576f44f..83edb6e 100644 --- a/.env.example +++ b/.env.example @@ -10,3 +10,7 @@ DB_PASSWORD=your_db_password # Admin Database (for database creation/deletion) ADMIN_DB_NAME=postgres + +# Culqui Keys +CULQI_PRIVATE_KEY="sk_test_xxx" +VITE_CULQI_PUBLIC_KEY="pk_test_xxx" \ No newline at end of file diff --git a/.env.test b/.env.test index 7b1edd0..3b81323 100644 --- a/.env.test +++ b/.env.test @@ -1,4 +1,8 @@ DATABASE_URL="postgresql://diego@localhost:5432/fullstock_test?schema=public" # Admin Database (for database creation/deletion) -ADMIN_DB_NAME=postgres \ No newline at end of file +ADMIN_DB_NAME=postgres + +# Culqui Keys +CULQI_PRIVATE_KEY="sk_test_xxx" +VITE_CULQI_PUBLIC_KEY="pk_test_xxx" \ No newline at end of file diff --git a/prisma/migrations/20250806155625_add_payment_id_to_order/migration.sql b/prisma/migrations/20250806155625_add_payment_id_to_order/migration.sql new file mode 100644 index 0000000..1b545cf --- /dev/null +++ b/prisma/migrations/20250806155625_add_payment_id_to_order/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "orders" ADD COLUMN "payment_id" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4314c02..e0f992b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -103,6 +103,7 @@ model Order { userId Int @map("user_id") totalAmount Decimal @map("total_amount") @db.Decimal(10, 2) email String + paymentId String? @map("payment_id") firstName String @map("first_name") lastName String @map("last_name") company String? diff --git a/public/.well-known/appspecific/com.chrome.devtools.json b/public/.well-known/appspecific/com.chrome.devtools.json new file mode 100644 index 0000000..e69de29 diff --git a/src/hooks/use-culqui.ts b/src/hooks/use-culqui.ts new file mode 100644 index 0000000..d4445d0 --- /dev/null +++ b/src/hooks/use-culqui.ts @@ -0,0 +1,78 @@ +import { useEffect, useRef, useState } from "react"; + +export type CulqiChargeError = { + object: "error"; + type: string; + charge_id: string; + code: string; + decline_code: string | null; + merchant_message: string; + user_message: string; +}; + +export interface CulqiInstance { + open: () => void; + close: () => void; + token?: { id: string }; + error?: Error; + culqi?: () => void; +} + +export type CulqiConstructorType = new ( + publicKey: string, + config: object +) => CulqiInstance; + +declare global { + interface Window { + CulqiCheckout?: CulqiConstructorType; + } +} + +// Return type explicitly includes the constructor function +export function useCulqi() { + const [CulqiCheckout, setCulqiCheckout] = + useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const scriptRef = useRef(null); + + useEffect(() => { + if (window.CulqiCheckout) { + setCulqiCheckout(() => window.CulqiCheckout!); + return; + } + + setLoading(true); + const script = document.createElement("script"); + script.src = "https://js.culqi.com/checkout-js"; + script.async = true; + scriptRef.current = script; + + script.onload = () => { + if (window.CulqiCheckout) { + setCulqiCheckout(() => window.CulqiCheckout!); + } else { + setError( + new Error("Culqi script loaded but CulqiCheckout object not found") + ); + } + setLoading(false); + }; + + script.onerror = () => { + setError(new Error("Failed to load CulqiCheckout script")); + setLoading(false); + }; + + document.head.appendChild(script); + + return () => { + if (scriptRef.current) { + scriptRef.current.remove(); + } + }; + }, []); + + return { CulqiCheckout, loading, error }; +} diff --git a/src/routes/checkout/index.tsx b/src/routes/checkout/index.tsx index 063dd5f..1ceb7ae 100644 --- a/src/routes/checkout/index.tsx +++ b/src/routes/checkout/index.tsx @@ -1,6 +1,6 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { X } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { redirect, useNavigation, useSubmit } from "react-router"; import { z } from "zod"; @@ -13,6 +13,11 @@ import { Separator, SelectField, } from "@/components/ui"; +import { + useCulqi, + type CulqiChargeError, + type CulqiInstance, +} from "@/hooks/use-culqui"; import { calculateTotal, getCart } from "@/lib/cart"; import { type CartItem } from "@/models/cart.model"; import { getCurrentUser } from "@/services/auth.service"; @@ -22,20 +27,6 @@ import { commitSession, getSession } from "@/session.server"; import type { Route } from "./+types"; -interface CulqiInstance { - open: () => void; - close: () => void; - token?: { id: string }; - error?: Error; - culqi?: () => void; -} - -declare global { - interface Window { - CulqiCheckout: new (publicKey: string, config: object) => CulqiInstance; - } -} - const countryOptions = [ { value: "AR", label: "Argentina" }, { value: "BO", label: "Bolivia" }, @@ -85,8 +76,10 @@ export async function action({ request }: Route.ActionArgs) { ) as CartItem[]; const token = formData.get("token") as string; + const total = Math.round(calculateTotal(cartItems) * 100); + const body = { - amount: 2000, // TODO: Calculate total dynamically + amount: total, currency_code: "PEN", email: shippingDetails.email, source_id: token, @@ -97,18 +90,19 @@ export async function action({ request }: Route.ActionArgs) { method: "POST", headers: { "content-type": "application/json", - Authorization: `Bearer sk_test_EC8oOLd3ZiCTKqjN`, // TODO: Use environment variable + Authorization: `Bearer ${process.env.CULQI_PRIVATE_KEY}`, }, body: JSON.stringify(body), }); if (!response.ok) { - const errorData = await response.json(); + const errorData = (await response.json()) as CulqiChargeError; console.error("Error creating charge:", errorData); - // TODO: Handle error appropriately - throw new Error("Error processing payment"); + return { error: errorData.user_message || "Error processing payment" }; } + const chargeData = await response.json(); + const items = cartItems.map((item) => ({ productId: item.product.id, quantity: item.quantity, @@ -117,9 +111,13 @@ export async function action({ request }: Route.ActionArgs) { imgSrc: item.product.imgSrc, })); - // TODO - // @ts-expect-error Arreglar el tipo de shippingDetails - const { id: orderId } = await createOrder(items, shippingDetails); // TODO: Add payment information to the order + const { id: orderId } = await createOrder( + items, + // TODO + // @ts-expect-error Arreglar el tipo de shippingDetails + shippingDetails, + chargeData.id + ); await deleteRemoteCart(request); const session = await getSession(request.headers.get("Cookie")); @@ -151,14 +149,18 @@ export async function loader({ request }: Route.LoaderArgs) { return user ? { user, cart, total } : { cart, total }; } -export default function Checkout({ loaderData }: Route.ComponentProps) { +export default function Checkout({ + loaderData, + actionData, +}: Route.ComponentProps) { const { user, cart, total } = loaderData; const navigation = useNavigation(); const submit = useSubmit(); const loading = navigation.state === "submitting"; + const paymentError = actionData?.error; const [culqui, setCulqui] = useState(null); - const scriptRef = useRef(null); + const { CulqiCheckout } = useCulqi(); const { register, @@ -183,96 +185,56 @@ export default function Checkout({ loaderData }: Route.ComponentProps) { }); useEffect(() => { - // Function to load the Culqi script - const loadCulqiScript = (): Promise => { - return new Promise((resolve, reject) => { - if (window.CulqiCheckout) { - resolve(window.CulqiCheckout); - return; - } - - // Create script element - const script = document.createElement("script"); - script.src = "https://js.culqi.com/checkout-js"; - script.async = true; - - // Store reference for cleanup - scriptRef.current = script; - - script.onload = () => { - if (window.CulqiCheckout) { - resolve(window.CulqiCheckout); - } else { - reject( - new Error( - "Culqi script loaded but CulqiCheckout object not found" - ) - ); - } - }; - - script.onerror = () => { - reject(new Error("Failed to load CulqiCheckout script")); - }; - - document.head.appendChild(script); - }); + if (!CulqiCheckout) return; + + const config = { + settings: { + currency: "PEN", + amount: Math.round(total * 100), + }, + client: { + email: user?.email, + }, + options: { + paymentMethods: { + tarjeta: true, + yape: false, + }, + }, + appearance: {}, }; - loadCulqiScript() - .then((CulqiCheckout) => { - const config = { - settings: { - currency: "PEN", - amount: total * 100, - }, - client: { - email: user?.email, - }, - options: { - paymentMethods: { - tarjeta: true, - yape: false, - }, + const culqiInstance = new CulqiCheckout( + import.meta.env.VITE_CULQI_PUBLIC_KEY as string, + config + ); + + culqiInstance.culqi = () => { + if (culqiInstance.token) { + const token = culqiInstance.token.id; + culqiInstance.close(); + const formData = getValues(); + submit( + { + shippingDetailsJson: JSON.stringify(formData), + cartItemsJson: JSON.stringify(cart.items), + token, }, - appearance: {}, - }; - - const publicKey = "pk_test_Ws4NXfH95QXlZgaz"; - const culqiInstance = new CulqiCheckout(publicKey, config); - - const handleCulqiAction = () => { - if (culqiInstance.token) { - const token = culqiInstance.token.id; - culqiInstance.close(); - const formData = getValues(); - submit( - { - shippingDetailsJson: JSON.stringify(formData), - cartItemsJson: JSON.stringify(cart.items), - token, - }, - { method: "POST" } - ); - } else { - console.log("Error : ", culqiInstance.error); - } - }; - - culqiInstance.culqi = handleCulqiAction; + { method: "POST" } + ); + } else { + console.log("Error : ", culqiInstance.error); + } + }; - setCulqui(culqiInstance); - }) - .catch((error) => { - console.error("Error loading Culqi script:", error); - }); + setCulqui(culqiInstance); return () => { - if (scriptRef.current) { - scriptRef.current.remove(); + if (culqiInstance) { + culqiInstance.close(); } }; - }, [total, user, submit, getValues, cart.items]); + }, [total, user, submit, getValues, cart.items, CulqiCheckout]); async function onSubmit() { if (culqui) { @@ -397,12 +359,18 @@ export default function Checkout({ loaderData }: Route.ComponentProps) { /> - + {paymentError && ( +

{paymentError}

+ )} -
); diff --git a/src/routes/login/index.tsx b/src/routes/login/index.tsx index ebab02b..49cf1a5 100644 --- a/src/routes/login/index.tsx +++ b/src/routes/login/index.tsx @@ -32,7 +32,7 @@ export async function action({ request }: Route.ActionArgs) { try { // Proceso de login nuevo const user = await prisma.user.findUnique({ where: { email } }); - if (!user) { + if (!user || user.isGuest) { return { error: "Correo electrónico o contraseña inválidos" }; } diff --git a/src/routes/signup/index.tsx b/src/routes/signup/index.tsx index bd32067..716d1c4 100644 --- a/src/routes/signup/index.tsx +++ b/src/routes/signup/index.tsx @@ -45,7 +45,7 @@ export async function action({ request }: Route.ActionArgs) { const existingUser = await prisma.user.findUnique({ where: { email: email }, }); - if (existingUser) { + if (existingUser && !existingUser.isGuest) { return { error: "El correo electrónico ya existe" }; } @@ -58,8 +58,10 @@ export async function action({ request }: Route.ActionArgs) { name: null, }; - const user = await prisma.user.create({ - data: newUser, + const user = await prisma.user.upsert({ + where: { email: email }, + update: newUser, + create: newUser, }); session.set("userId", user.id); @@ -92,6 +94,7 @@ export async function action({ request }: Route.ActionArgs) { export async function loader({ request }: Route.LoaderArgs) { await redirectIfAuthenticated(request); + return null; } export default function Signup({ actionData }: Route.ComponentProps) { diff --git a/src/services/order.service.ts b/src/services/order.service.ts index f29f93d..6f5948a 100644 --- a/src/services/order.service.ts +++ b/src/services/order.service.ts @@ -8,7 +8,8 @@ import { getOrCreateUser } from "./user.service"; export async function createOrder( items: CartItemInput[], - formData: OrderDetails + formData: OrderDetails, + paymentId: string ): Promise { const shippingDetails = formData; const user = await getOrCreateUser(shippingDetails.email); @@ -31,6 +32,7 @@ export async function createOrder( imgSrc: item.imgSrc, })), }, + paymentId: paymentId, }, include: { items: true, diff --git a/src/services/user.service.ts b/src/services/user.service.ts index d389630..f125866 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -54,5 +54,5 @@ export async function verifyUniqueEmail(email: string): Promise { where: { email }, }); - return user ? false : true; + return user && !user.isGuest ? false : true; }