Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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"
6 changes: 5 additions & 1 deletion .env.test
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
DATABASE_URL="postgresql://diego@localhost:5432/fullstock_test?schema=public"

# Admin Database (for database creation/deletion)
ADMIN_DB_NAME=postgres
ADMIN_DB_NAME=postgres

# Culqui Keys
CULQI_PRIVATE_KEY="sk_test_xxx"
VITE_CULQI_PUBLIC_KEY="pk_test_xxx"
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "orders" ADD COLUMN "payment_id" TEXT;
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
Empty file.
78 changes: 78 additions & 0 deletions src/hooks/use-culqui.ts
Original file line number Diff line number Diff line change
@@ -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<CulqiConstructorType | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<Error | null>(null);
const scriptRef = useRef<HTMLScriptElement | null>(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 };
}
186 changes: 77 additions & 109 deletions src/routes/checkout/index.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand All @@ -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" },
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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"));
Expand Down Expand Up @@ -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<CulqiInstance | null>(null);
const scriptRef = useRef<HTMLScriptElement | null>(null);
const { CulqiCheckout } = useCulqi();

const {
register,
Expand All @@ -183,96 +185,56 @@ export default function Checkout({ loaderData }: Route.ComponentProps) {
});

useEffect(() => {
// Function to load the Culqi script
const loadCulqiScript = (): Promise<Window["CulqiCheckout"]> => {
return new Promise<Window["CulqiCheckout"]>((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) {
Expand Down Expand Up @@ -397,12 +359,18 @@ export default function Checkout({ loaderData }: Route.ComponentProps) {
/>
</div>
</fieldset>
<Button size="xl" className="w-full mt-6" disabled={!isValid}>
<Button
size="xl"
className="w-full mt-6"
disabled={!isValid || !CulqiCheckout || loading}
>
{loading ? "Procesando..." : "Confirmar Orden"}
</Button>
{paymentError && (
<p className="text-red-500 mt-4 text-center">{paymentError}</p>
)}
</form>
</div>
<div id="culqi-container"></div>
</Container>
</Section>
);
Expand Down
2 changes: 1 addition & 1 deletion src/routes/login/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
}

Expand Down
Loading