mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 11:36:42 +00:00
Changes
Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
parent
13b3951a1e
commit
5ef5bab121
9 changed files with 512 additions and 0 deletions
20
src/components/mika/PaymentTestModeBanner.tsx
Normal file
20
src/components/mika/PaymentTestModeBanner.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
const clientToken = import.meta.env.VITE_PAYMENTS_CLIENT_TOKEN as string | undefined;
|
||||
|
||||
export function PaymentTestModeBanner() {
|
||||
if (!clientToken?.startsWith("test_")) return null;
|
||||
|
||||
return (
|
||||
<div className="w-full bg-warning/15 border-b border-warning/30 px-4 py-2 text-center text-xs sm:text-sm text-warning-foreground">
|
||||
<span className="font-semibold text-warning">Modo de teste:</span>{" "}
|
||||
pagamentos no preview não cobram dinheiro real.{" "}
|
||||
<a
|
||||
href="https://docs.lovable.dev/features/payments#test-and-live-environments"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline font-medium text-warning"
|
||||
>
|
||||
Saiba mais
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
src/hooks/use-paddle-checkout.ts
Normal file
44
src/hooks/use-paddle-checkout.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { initializePaddle, getPaddlePriceId } from "@/lib/paddle";
|
||||
|
||||
interface CheckoutOptions {
|
||||
priceId: string;
|
||||
quantity?: number;
|
||||
customerEmail?: string;
|
||||
userId: string;
|
||||
successUrl?: string;
|
||||
}
|
||||
|
||||
export function usePaddleCheckout() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const openCheckout = async (opts: CheckoutOptions) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await initializePaddle();
|
||||
const paddlePriceId = await getPaddlePriceId(opts.priceId);
|
||||
|
||||
window.Paddle.Checkout.open({
|
||||
items: [{ priceId: paddlePriceId, quantity: opts.quantity ?? 1 }],
|
||||
customer: opts.customerEmail ? { email: opts.customerEmail } : undefined,
|
||||
customData: { userId: opts.userId },
|
||||
settings: {
|
||||
displayMode: "overlay",
|
||||
successUrl: opts.successUrl || `${window.location.origin}/checkout/sucesso`,
|
||||
allowLogout: false,
|
||||
variant: "one-page",
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast.error("Não foi possível abrir o checkout. Tente novamente.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { openCheckout, loading };
|
||||
}
|
||||
71
src/lib/paddle.ts
Normal file
71
src/lib/paddle.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { supabase } from "@/integrations/supabase/client";
|
||||
|
||||
const clientToken = import.meta.env.VITE_PAYMENTS_CLIENT_TOKEN as string | undefined;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Paddle: any;
|
||||
}
|
||||
}
|
||||
|
||||
let paddleInitialized = false;
|
||||
let initPromise: Promise<void> | null = null;
|
||||
|
||||
export function getPaddleEnv(): "sandbox" | "live" {
|
||||
return clientToken?.startsWith("test_") ? "sandbox" : "live";
|
||||
}
|
||||
|
||||
export async function initializePaddle(): Promise<void> {
|
||||
if (paddleInitialized) return;
|
||||
if (initPromise) return initPromise;
|
||||
|
||||
if (!clientToken) {
|
||||
throw new Error("VITE_PAYMENTS_CLIENT_TOKEN não está configurado");
|
||||
}
|
||||
|
||||
initPromise = new Promise<void>((resolve, reject) => {
|
||||
const existing = document.querySelector<HTMLScriptElement>('script[data-paddle="true"]');
|
||||
const onLoad = () => {
|
||||
try {
|
||||
const environment = clientToken.startsWith("test_") ? "sandbox" : "production";
|
||||
window.Paddle.Environment.set(environment);
|
||||
window.Paddle.Initialize({ token: clientToken });
|
||||
paddleInitialized = true;
|
||||
resolve();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
if (window.Paddle) onLoad();
|
||||
else existing.addEventListener("load", onLoad);
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://cdn.paddle.com/paddle/v2/paddle.js";
|
||||
script.dataset.paddle = "true";
|
||||
script.onload = onLoad;
|
||||
script.onerror = () => reject(new Error("Falha ao carregar Paddle.js"));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
const priceIdCache = new Map<string, string>();
|
||||
|
||||
export async function getPaddlePriceId(priceId: string): Promise<string> {
|
||||
if (priceIdCache.has(priceId)) return priceIdCache.get(priceId)!;
|
||||
|
||||
const environment = getPaddleEnv();
|
||||
const { data, error } = await supabase.functions.invoke("get-paddle-price", {
|
||||
body: { priceId, environment },
|
||||
});
|
||||
if (error || !data?.paddleId) {
|
||||
throw new Error(`Não foi possível resolver o preço: ${priceId}`);
|
||||
}
|
||||
priceIdCache.set(priceId, data.paddleId);
|
||||
return data.paddleId;
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ import { Route as IndexRouteImport } from './routes/index'
|
|||
import { Route as PainelIndexRouteImport } from './routes/painel.index'
|
||||
import { Route as PainelFaturamentoRouteImport } from './routes/painel.faturamento'
|
||||
import { Route as PainelConfiguracoesRouteImport } from './routes/painel.configuracoes'
|
||||
import { Route as CheckoutSucessoRouteImport } from './routes/checkout.sucesso'
|
||||
|
||||
const SignupRoute = SignupRouteImport.update({
|
||||
id: '/signup',
|
||||
|
|
@ -64,6 +65,11 @@ const PainelConfiguracoesRoute = PainelConfiguracoesRouteImport.update({
|
|||
path: '/configuracoes',
|
||||
getParentRoute: () => PainelRoute,
|
||||
} as any)
|
||||
const CheckoutSucessoRoute = CheckoutSucessoRouteImport.update({
|
||||
id: '/checkout/sucesso',
|
||||
path: '/checkout/sucesso',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
|
|
@ -72,6 +78,7 @@ export interface FileRoutesByFullPath {
|
|||
'/recuperar-senha': typeof RecuperarSenhaRoute
|
||||
'/redefinir-senha': typeof RedefinirSenhaRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/checkout/sucesso': typeof CheckoutSucessoRoute
|
||||
'/painel/configuracoes': typeof PainelConfiguracoesRoute
|
||||
'/painel/faturamento': typeof PainelFaturamentoRoute
|
||||
'/painel/': typeof PainelIndexRoute
|
||||
|
|
@ -82,6 +89,7 @@ export interface FileRoutesByTo {
|
|||
'/recuperar-senha': typeof RecuperarSenhaRoute
|
||||
'/redefinir-senha': typeof RedefinirSenhaRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/checkout/sucesso': typeof CheckoutSucessoRoute
|
||||
'/painel/configuracoes': typeof PainelConfiguracoesRoute
|
||||
'/painel/faturamento': typeof PainelFaturamentoRoute
|
||||
'/painel': typeof PainelIndexRoute
|
||||
|
|
@ -94,6 +102,7 @@ export interface FileRoutesById {
|
|||
'/recuperar-senha': typeof RecuperarSenhaRoute
|
||||
'/redefinir-senha': typeof RedefinirSenhaRoute
|
||||
'/signup': typeof SignupRoute
|
||||
'/checkout/sucesso': typeof CheckoutSucessoRoute
|
||||
'/painel/configuracoes': typeof PainelConfiguracoesRoute
|
||||
'/painel/faturamento': typeof PainelFaturamentoRoute
|
||||
'/painel/': typeof PainelIndexRoute
|
||||
|
|
@ -107,6 +116,7 @@ export interface FileRouteTypes {
|
|||
| '/recuperar-senha'
|
||||
| '/redefinir-senha'
|
||||
| '/signup'
|
||||
| '/checkout/sucesso'
|
||||
| '/painel/configuracoes'
|
||||
| '/painel/faturamento'
|
||||
| '/painel/'
|
||||
|
|
@ -117,6 +127,7 @@ export interface FileRouteTypes {
|
|||
| '/recuperar-senha'
|
||||
| '/redefinir-senha'
|
||||
| '/signup'
|
||||
| '/checkout/sucesso'
|
||||
| '/painel/configuracoes'
|
||||
| '/painel/faturamento'
|
||||
| '/painel'
|
||||
|
|
@ -128,6 +139,7 @@ export interface FileRouteTypes {
|
|||
| '/recuperar-senha'
|
||||
| '/redefinir-senha'
|
||||
| '/signup'
|
||||
| '/checkout/sucesso'
|
||||
| '/painel/configuracoes'
|
||||
| '/painel/faturamento'
|
||||
| '/painel/'
|
||||
|
|
@ -140,6 +152,7 @@ export interface RootRouteChildren {
|
|||
RecuperarSenhaRoute: typeof RecuperarSenhaRoute
|
||||
RedefinirSenhaRoute: typeof RedefinirSenhaRoute
|
||||
SignupRoute: typeof SignupRoute
|
||||
CheckoutSucessoRoute: typeof CheckoutSucessoRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
|
|
@ -207,6 +220,13 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof PainelConfiguracoesRouteImport
|
||||
parentRoute: typeof PainelRoute
|
||||
}
|
||||
'/checkout/sucesso': {
|
||||
id: '/checkout/sucesso'
|
||||
path: '/checkout/sucesso'
|
||||
fullPath: '/checkout/sucesso'
|
||||
preLoaderRoute: typeof CheckoutSucessoRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -232,7 +252,17 @@ const rootRouteChildren: RootRouteChildren = {
|
|||
RecuperarSenhaRoute: RecuperarSenhaRoute,
|
||||
RedefinirSenhaRoute: RedefinirSenhaRoute,
|
||||
SignupRoute: SignupRoute,
|
||||
CheckoutSucessoRoute: CheckoutSucessoRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
|
||||
import type { getRouter } from './router.tsx'
|
||||
import type { createStart } from '@tanstack/react-start'
|
||||
declare module '@tanstack/react-start' {
|
||||
interface Register {
|
||||
ssr: true
|
||||
router: Awaited<ReturnType<typeof getRouter>>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
50
src/routes/checkout.sucesso.tsx
Normal file
50
src/routes/checkout.sucesso.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export const Route = createFileRoute("/checkout/sucesso")({
|
||||
component: CheckoutSuccessPage,
|
||||
});
|
||||
|
||||
function CheckoutSuccessPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
// Invalida queries de assinatura para refletir o novo estado quando o webhook chegar
|
||||
queryClient.invalidateQueries({ queryKey: ["subscription"] });
|
||||
const t = setTimeout(() => {
|
||||
navigate({ to: "/painel" });
|
||||
}, 6000);
|
||||
return () => clearTimeout(t);
|
||||
}, [navigate, queryClient]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center px-4">
|
||||
<div className="max-w-md w-full text-center space-y-6">
|
||||
<div className="mx-auto w-16 h-16 rounded-full bg-success/15 flex items-center justify-center">
|
||||
<CheckCircle2 className="h-9 w-9 text-success" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Assinatura confirmada!</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Estamos provisionando seu acesso. Em alguns segundos seu plano estará ativo.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
<Button asChild className="rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground">
|
||||
<Link to="/painel">Ir para o painel</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" className="rounded-lg">
|
||||
<Link to="/painel/faturamento">Ver faturamento</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Você será redirecionado em instantes…</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue