mirror of
https://github.com/domfelipe/mika-agent-assist.git
synced 2026-08-07 06:56:44 +00:00
Criou landing page + theme
X-Lovable-Edit-ID: edt-ea60aff2-b321-45d5-a0c3-99cb5635b9f5 Co-authored-by: domfelipe <53182096+domfelipe@users.noreply.github.com>
This commit is contained in:
commit
4f2d128b28
27 changed files with 1542 additions and 127 deletions
5
.env
Normal file
5
.env
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
SUPABASE_PUBLISHABLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InNtc2FybWdvaXJsY2VkbXF2ZGdjIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzY0MzczNjAsImV4cCI6MjA5MjAxMzM2MH0.AjMSQOn3BtzV-fJ2x9SWoM2ozjLyPD6g3dmTSeCT9II"
|
||||
SUPABASE_URL="https://smsarmgoirlcedmqvdgc.supabase.co"
|
||||
VITE_SUPABASE_PROJECT_ID="smsarmgoirlcedmqvdgc"
|
||||
VITE_SUPABASE_PUBLISHABLE_KEY="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InNtc2FybWdvaXJsY2VkbXF2ZGdjIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzY0MzczNjAsImV4cCI6MjA5MjAxMzM2MH0.AjMSQOn3BtzV-fJ2x9SWoM2ozjLyPD6g3dmTSeCT9II"
|
||||
VITE_SUPABASE_URL="https://smsarmgoirlcedmqvdgc.supabase.co"
|
||||
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
|
|
@ -40,8 +40,9 @@
|
|||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@supabase/supabase-js": "^2.103.3",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tanstack/react-query": "^5.83.0",
|
||||
"@tanstack/react-query": "^5.99.0",
|
||||
"@tanstack/react-router": "^1.168.0",
|
||||
"@tanstack/react-start": "^1.167.14",
|
||||
"@tanstack/router-plugin": "^1.167.10",
|
||||
|
|
@ -50,12 +51,14 @@
|
|||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.575.0",
|
||||
"react": "^19.2.0",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-hook-form": "^7.72.1",
|
||||
"react-imask": "^7.6.1",
|
||||
"react-resizable-panels": "^4.6.5",
|
||||
"recharts": "^2.15.4",
|
||||
"sonner": "^2.0.7",
|
||||
|
|
@ -64,7 +67,7 @@
|
|||
"tw-animate-css": "^1.3.4",
|
||||
"vaul": "^1.1.2",
|
||||
"vite-tsconfig-paths": "^6.0.2",
|
||||
"zod": "^3.24.2"
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.32.0",
|
||||
|
|
|
|||
5
public/favicon.svg
Normal file
5
public/favicon.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<rect width="64" height="64" rx="14" fill="#FFFFFF"/>
|
||||
<text x="32" y="46" font-family="Inter, system-ui, sans-serif" font-size="44" font-weight="800"
|
||||
text-anchor="middle" fill="#6366F1" letter-spacing="-2">M<tspan fill="#F59E0B">.</tspan></text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 349 B |
20
public/og.svg
Normal file
20
public/og.svg
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
|
||||
<defs>
|
||||
<radialGradient id="bg" cx="50%" cy="50%" r="75%">
|
||||
<stop offset="0%" stop-color="#6366F1" />
|
||||
<stop offset="100%" stop-color="#0F172A" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="1200" height="630" fill="url(#bg)" />
|
||||
<g font-family="Inter, system-ui, sans-serif" text-anchor="middle">
|
||||
<text x="600" y="320" font-size="120" font-weight="800" fill="#FFFFFF" letter-spacing="-4">
|
||||
Mika<tspan fill="#F59E0B">.</tspan>
|
||||
</text>
|
||||
<text x="600" y="400" font-size="36" font-weight="500" fill="#F1F5F9">
|
||||
Seu assistente de IA no Telegram
|
||||
</text>
|
||||
<text x="600" y="570" font-size="24" font-weight="400" fill="#94A3B8">
|
||||
mika.domco.ai
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 804 B |
119
src/components/mika/EnterpriseLeadForm.tsx
Normal file
119
src/components/mika/EnterpriseLeadForm.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { IMaskInput } from "react-imask";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
const schema = z.object({
|
||||
company_name: z.string().min(2, "Informe o nome da empresa").max(120),
|
||||
contact_name: z.string().min(2, "Informe seu nome").max(120),
|
||||
email: z.string().email("Formato de e-mail inválido."),
|
||||
phone: z.string().min(14, "Telefone inválido").max(20),
|
||||
team_size: z.enum(["1-10", "11-50", "51-200", "200+"], { message: "Selecione o tamanho da equipe" }),
|
||||
message: z.string().max(1000).optional(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
export function EnterpriseLeadForm() {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<FormValues>({ resolver: zodResolver(schema) });
|
||||
|
||||
const onSubmit = async (data: FormValues) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// Etapa 2: gravar em enterprise_leads via supabase.from(...).insert(...)
|
||||
// Por ora: feedback otimista para o usuário.
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
toast.success("Recebemos seu contato! Falamos com você em até 1 dia útil.");
|
||||
reset();
|
||||
} catch {
|
||||
toast.error("Algo deu errado do nosso lado. Já estamos investigando.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const phone = watch("phone") || "";
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="company_name">Empresa</Label>
|
||||
<Input id="company_name" {...register("company_name")} placeholder="Acme S/A" />
|
||||
{errors.company_name && <p className="text-xs text-destructive">{errors.company_name.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="contact_name">Seu nome</Label>
|
||||
<Input id="contact_name" {...register("contact_name")} placeholder="Maria Silva" />
|
||||
{errors.contact_name && <p className="text-xs text-destructive">{errors.contact_name.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">E-mail corporativo</Label>
|
||||
<Input id="email" type="email" {...register("email")} placeholder="voce@empresa.com" />
|
||||
{errors.email && <p className="text-xs text-destructive">{errors.email.message}</p>}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="phone">Telefone</Label>
|
||||
<IMaskInput
|
||||
id="phone"
|
||||
mask="(00) 00000-0000"
|
||||
value={phone}
|
||||
onAccept={(value) => setValue("phone", value as string, { shouldValidate: true })}
|
||||
placeholder="(11) 99999-9999"
|
||||
className="flex h-10 w-full rounded-lg border border-input bg-transparent px-3 py-2 text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
{errors.phone && <p className="text-xs text-destructive">{errors.phone.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="team_size">Tamanho da equipe</Label>
|
||||
<Select onValueChange={(v) => setValue("team_size", v as FormValues["team_size"], { shouldValidate: true })}>
|
||||
<SelectTrigger id="team_size"><SelectValue placeholder="Selecione" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1-10">1-10 pessoas</SelectItem>
|
||||
<SelectItem value="11-50">11-50 pessoas</SelectItem>
|
||||
<SelectItem value="51-200">51-200 pessoas</SelectItem>
|
||||
<SelectItem value="200+">200+ pessoas</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.team_size && <p className="text-xs text-destructive">{errors.team_size.message}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="message">Mensagem (opcional)</Label>
|
||||
<Textarea id="message" {...register("message")} rows={3} placeholder="Conta um pouco do seu caso de uso…" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground transition-all duration-150 active:scale-[0.98]"
|
||||
>
|
||||
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
Enviar
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
58
src/components/mika/FaqSection.tsx
Normal file
58
src/components/mika/FaqSection.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
q: "O que é o Mika?",
|
||||
a: "Mika é uma plataforma brasileira que entrega para você um agente de IA pessoal hospedado em VPS gerenciada, acessível direto pelo Telegram. Ele aprende com você, lembra de contexto e executa tarefas com integrações reais.",
|
||||
},
|
||||
{
|
||||
q: "Como funciona o Telegram?",
|
||||
a: "Após assinar, você recebe um QR code para conectar seu agente Mika ao seu Telegram. A partir daí, basta conversar com ele como faria com um assistente humano — ele responde direto no chat.",
|
||||
},
|
||||
{
|
||||
q: "Posso cancelar quando quiser?",
|
||||
a: "Sim, a qualquer momento, sem multa. O acesso continua até o fim do período já pago e não há cobranças adicionais depois disso.",
|
||||
},
|
||||
{
|
||||
q: "Meus dados ficam seguros?",
|
||||
a: "Seu agente roda em uma VPS gerenciada exclusivamente para você, com criptografia em trânsito e em repouso. Nunca usamos seus dados para treinar modelos. Estamos em conformidade com a LGPD.",
|
||||
},
|
||||
{
|
||||
q: "Qual a diferença entre os planos?",
|
||||
a: "Os planos diferem em capacidade de memória, número de skills, performance da VPS, modelos de IA disponíveis e nível de suporte. O Professional inclui VPS dedicada e modelos premium; o Enterprise inclui múltiplos agentes, SSO e SLA.",
|
||||
},
|
||||
{
|
||||
q: "Como funciona o Skill Studio?",
|
||||
a: "É onde você cria automações personalizadas em linguagem natural — basta descrever o que quer e o Mika gera a skill. Sem código.",
|
||||
},
|
||||
{
|
||||
q: "O Mika funciona em grupo do Telegram?",
|
||||
a: "Sim. Você pode adicionar o Mika a grupos e mencioná-lo para receber respostas, mantendo a privacidade das outras conversas.",
|
||||
},
|
||||
{
|
||||
q: "Tem trial grátis?",
|
||||
a: "Não oferecemos trial, mas garantimos reembolso integral nos primeiros 7 dias caso o produto não atenda suas expectativas.",
|
||||
},
|
||||
];
|
||||
|
||||
export function FaqSection() {
|
||||
return (
|
||||
<section id="faq" className="py-20 sm:py-28 bg-muted/40">
|
||||
<div className="mx-auto max-w-3xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold tracking-tight">Perguntas frequentes</h2>
|
||||
<p className="mt-4 text-lg text-muted-foreground">As dúvidas mais comuns sobre o Mika.</p>
|
||||
</div>
|
||||
|
||||
<Accordion type="single" collapsible className="mt-12 bg-card rounded-xl border border-border shadow-soft px-2">
|
||||
{faqs.map((f, i) => (
|
||||
<AccordionItem key={f.q} value={`item-${i}`} className="border-border last:border-b-0">
|
||||
<AccordionTrigger className="px-4 text-left font-medium hover:no-underline">{f.q}</AccordionTrigger>
|
||||
<AccordionContent className="px-4 text-muted-foreground leading-relaxed">{f.a}</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
44
src/components/mika/FeaturesSection.tsx
Normal file
44
src/components/mika/FeaturesSection.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { Brain, Sparkles, Cable, Clock, MessageCircleHeart, ShieldCheck, type LucideIcon } from "lucide-react";
|
||||
|
||||
type Feature = { icon: LucideIcon; title: string; description: string };
|
||||
|
||||
const features: Feature[] = [
|
||||
{ icon: Brain, title: "Memória Persistente", description: "O agente lembra tudo sobre você entre sessões — preferências, contatos e contexto contínuo." },
|
||||
{ icon: Sparkles, title: "Skills Personalizadas", description: "Crie automações em linguagem natural no Skill Studio. Sem código." },
|
||||
{ icon: Cable, title: "Integração Google Workspace", description: "Gmail, Calendar e Drive conectados em 1 clique e prontos para usar." },
|
||||
{ icon: Clock, title: "Agendamentos Automáticos", description: "Cron em linguagem natural — ex: \"toda segunda às 9h me envie resumo da semana\"." },
|
||||
{ icon: MessageCircleHeart, title: "Suporte em Português", description: "Time brasileiro respondendo em horário comercial. Sem tradutor automático." },
|
||||
{ icon: ShieldCheck, title: "Privacidade Total", description: "Seu agente roda na sua VPS. Sem treinamento com seus dados." },
|
||||
];
|
||||
|
||||
export function FeaturesSection() {
|
||||
return (
|
||||
<section id="recursos" className="py-20 sm:py-28 bg-background">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold tracking-tight text-balance">
|
||||
Tudo que um assistente pessoal deveria ter
|
||||
</h2>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
Recursos pensados para profissionais brasileiros que querem produtividade sem complicação.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-14 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{features.map(({ icon: Icon, title, description }) => (
|
||||
<article
|
||||
key={title}
|
||||
className="group rounded-xl border border-border bg-card p-6 shadow-soft hover:shadow-lg hover:border-primary/30 transition-all duration-200"
|
||||
>
|
||||
<div className="h-12 w-12 rounded-full bg-primary/10 grid place-items-center text-primary">
|
||||
<Icon className="h-6 w-6" aria-hidden />
|
||||
</div>
|
||||
<h3 className="mt-5 text-lg font-semibold">{title}</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground leading-relaxed">{description}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
148
src/components/mika/HeroSection.tsx
Normal file
148
src/components/mika/HeroSection.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { ArrowRight, Send } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const messages = [
|
||||
{ who: "user", text: "Resuma meus e-mails de hoje" },
|
||||
{
|
||||
who: "bot",
|
||||
text: "Você tem 3 e-mails importantes:",
|
||||
bullets: [
|
||||
"📅 Carla — confirmar reunião de quinta às 15h",
|
||||
"💰 Financeiro — fatura do servidor vence amanhã",
|
||||
"📝 Time — feedback no documento do Q2",
|
||||
],
|
||||
},
|
||||
{ who: "user", text: "Marca a reunião com a Carla" },
|
||||
{ who: "bot", text: "Pronto ✅ — agendado para quinta, 15h. Calendário atualizado." },
|
||||
];
|
||||
|
||||
export function HeroSection() {
|
||||
return (
|
||||
<section className="relative min-h-[85vh] pt-32 pb-16 overflow-hidden bg-gradient-hero">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid lg:grid-cols-2 gap-12 lg:gap-8 items-center">
|
||||
{/* Left: copy */}
|
||||
<div className="text-center lg:text-left">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="inline-flex items-center gap-2 bg-primary/10 text-primary rounded-full px-3 py-1 text-sm font-medium"
|
||||
>
|
||||
🇧🇷 Feito no Brasil · Suporte em português
|
||||
</motion.div>
|
||||
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.1 }}
|
||||
className="mt-6 text-4xl sm:text-5xl lg:text-[3rem] xl:text-[3.5rem] leading-[1.1] font-bold tracking-tight text-balance"
|
||||
>
|
||||
Seu assistente pessoal de IA, sempre disponível no{" "}
|
||||
<span className="bg-gradient-primary bg-clip-text text-transparent">Telegram</span>
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="mt-6 text-lg text-muted-foreground max-w-xl mx-auto lg:mx-0 text-balance"
|
||||
>
|
||||
O Mika entrega um agente de IA próprio que aprende com você, gerencia sua agenda,
|
||||
seus e-mails e suas tarefas — tudo em português e conversando direto no Telegram.
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
className="mt-8 flex flex-col sm:flex-row gap-3 justify-center lg:justify-start"
|
||||
>
|
||||
<Button asChild size="lg" className="rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground transition-all duration-150 active:scale-[0.98] shadow-glow">
|
||||
<Link to="/signup">
|
||||
Começar agora <ArrowRight className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="lg" className="rounded-lg">
|
||||
<a href="#planos">Ver planos</a>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Right: Telegram mockup */}
|
||||
<TelegramMockup />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TelegramMockup() {
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="show"
|
||||
variants={{
|
||||
hidden: {},
|
||||
show: { transition: { staggerChildren: 0.3, delayChildren: 0.4 } },
|
||||
}}
|
||||
className="relative mx-auto w-full max-w-md"
|
||||
aria-label="Demonstração de conversa no Telegram com o agente Mika"
|
||||
>
|
||||
<div className="absolute -inset-6 bg-gradient-primary opacity-20 blur-3xl rounded-full" aria-hidden />
|
||||
<div className="relative bg-card border border-border rounded-2xl shadow-soft overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-primary text-primary-foreground px-4 py-3 flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-full bg-white/20 grid place-items-center font-bold">M</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold leading-tight">Mika</p>
|
||||
<p className="text-xs text-white/80">online · seu agente pessoal</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Messages */}
|
||||
<div className="p-4 space-y-3 bg-muted/30 min-h-[420px]">
|
||||
{messages.map((m, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
variants={{
|
||||
hidden: { opacity: 0, y: 8 },
|
||||
show: { opacity: 1, y: 0 },
|
||||
}}
|
||||
className={m.who === "user" ? "flex justify-end" : "flex justify-start"}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
m.who === "user"
|
||||
? "max-w-[80%] rounded-2xl rounded-br-sm px-3 py-2 text-sm bg-primary text-primary-foreground"
|
||||
: "max-w-[85%] rounded-2xl rounded-bl-sm px-3 py-2 text-sm bg-card border border-border text-foreground"
|
||||
}
|
||||
>
|
||||
<p>{m.text}</p>
|
||||
{m.bullets && (
|
||||
<ul className="mt-2 space-y-1 text-xs text-muted-foreground">
|
||||
{m.bullets.map((b) => (
|
||||
<li key={b}>{b}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
{/* Input */}
|
||||
<div className="border-t border-border p-3 flex items-center gap-2 bg-card">
|
||||
<div className="flex-1 h-9 rounded-full bg-muted/60 px-3 grid items-center text-xs text-muted-foreground">
|
||||
Mensagem…
|
||||
</div>
|
||||
<div className="h-9 w-9 rounded-full bg-primary grid place-items-center text-primary-foreground">
|
||||
<Send className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
37
src/components/mika/HowItWorksSection.tsx
Normal file
37
src/components/mika/HowItWorksSection.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
const steps = [
|
||||
{ n: 1, title: "Escolha seu plano", description: "4 planos pensados para diferentes níveis de uso." },
|
||||
{ n: 2, title: "Conecte seu Telegram", description: "Em 30 segundos. Escaneie um QR code e pronto." },
|
||||
{ n: 3, title: "Personalize seu agente", description: "Defina nome, tom de voz, integrações e skills." },
|
||||
{ n: 4, title: "Converse e produza mais", description: "Mande mensagens como faria com um assistente humano." },
|
||||
];
|
||||
|
||||
export function HowItWorksSection() {
|
||||
return (
|
||||
<section id="como-funciona" className="py-20 sm:py-28 bg-muted/40">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold tracking-tight">Como funciona</h2>
|
||||
<p className="mt-4 text-lg text-muted-foreground">Do cadastro à primeira conversa em menos de 5 minutos.</p>
|
||||
</div>
|
||||
|
||||
<ol className="mt-16 grid grid-cols-1 lg:grid-cols-4 gap-10 lg:gap-4 relative">
|
||||
{steps.map((s, i) => (
|
||||
<li key={s.n} className="relative flex flex-col items-center text-center lg:px-4">
|
||||
{i < steps.length - 1 && (
|
||||
<div
|
||||
className="hidden lg:block absolute top-8 left-[calc(50%+2.5rem)] right-[-50%] h-px bg-primary/30"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
<div className="h-16 w-16 rounded-full bg-primary text-primary-foreground grid place-items-center font-bold text-xl shadow-glow z-10">
|
||||
{s.n}
|
||||
</div>
|
||||
<h3 className="mt-5 text-lg font-semibold">{s.title}</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground max-w-xs">{s.description}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
67
src/components/mika/LandingFooter.tsx
Normal file
67
src/components/mika/LandingFooter.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { Logo } from "./Logo";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
const cols = [
|
||||
{
|
||||
title: "Produto",
|
||||
items: [
|
||||
{ label: "Recursos", href: "#recursos" },
|
||||
{ label: "Planos", href: "#planos" },
|
||||
{ label: "Skills", href: "#" },
|
||||
{ label: "Mudanças", href: "#" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Empresa",
|
||||
items: [
|
||||
{ label: "Sobre", href: "#" },
|
||||
{ label: "Blog", href: "#" },
|
||||
{ label: "Contato", href: "#" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Legal",
|
||||
items: [
|
||||
{ label: "Termos de Uso", href: "#" },
|
||||
{ label: "Política de Privacidade", href: "#" },
|
||||
{ label: "LGPD", href: "#" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function LandingFooter() {
|
||||
return (
|
||||
<footer className="bg-background border-t border-border">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-14">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-10">
|
||||
<div>
|
||||
<Logo />
|
||||
<p className="mt-4 text-sm text-muted-foreground max-w-xs">
|
||||
Seu assistente de IA pessoal no Telegram, gerenciado e em português.
|
||||
</p>
|
||||
<Badge className="mt-4 bg-secondary/15 text-secondary hover:bg-secondary/15 border-0 font-semibold">
|
||||
Feito no Brasil 🇧🇷
|
||||
</Badge>
|
||||
</div>
|
||||
{cols.map((c) => (
|
||||
<div key={c.title}>
|
||||
<h4 className="text-sm font-semibold text-foreground">{c.title}</h4>
|
||||
<ul className="mt-4 space-y-3">
|
||||
{c.items.map((it) => (
|
||||
<li key={it.label}>
|
||||
<a href={it.href} className="text-sm text-muted-foreground hover:text-foreground transition-colors">
|
||||
{it.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-12 pt-6 border-t border-border text-center text-xs text-muted-foreground">
|
||||
© 2026 DOMCO — Todos os direitos reservados. Mika é um produto da DOMCO (AI Solutions On Demand).
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
102
src/components/mika/LandingHeader.tsx
Normal file
102
src/components/mika/LandingHeader.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Menu } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetTrigger, SheetTitle, SheetHeader } from "@/components/ui/sheet";
|
||||
import { Logo } from "./Logo";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const NAV = [
|
||||
{ href: "#recursos", label: "Recursos" },
|
||||
{ href: "#planos", label: "Planos" },
|
||||
{ href: "#como-funciona", label: "Como Funciona" },
|
||||
{ href: "#faq", label: "FAQ" },
|
||||
];
|
||||
|
||||
export function LandingHeader() {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 8);
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"fixed top-0 left-0 right-0 z-50 transition-all duration-200",
|
||||
scrolled
|
||||
? "bg-background/80 backdrop-blur-md border-b border-border"
|
||||
: "bg-transparent border-b border-transparent",
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between gap-4">
|
||||
<Logo />
|
||||
|
||||
<nav className="hidden md:flex items-center gap-8" aria-label="Navegação principal">
|
||||
{NAV.map((item) => (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<Button asChild variant="ghost" className="rounded-lg">
|
||||
<Link to="/login">Entrar</Link>
|
||||
</Button>
|
||||
<Button asChild className="rounded-lg bg-primary hover:bg-primary-dark text-primary-foreground transition-all duration-150 active:scale-[0.98]">
|
||||
<Link to="/signup">Começar agora</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex md:hidden items-center gap-1">
|
||||
<ThemeToggle />
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" aria-label="Abrir menu">
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-[85vw] max-w-sm">
|
||||
<SheetHeader>
|
||||
<SheetTitle><Logo /></SheetTitle>
|
||||
</SheetHeader>
|
||||
<nav className="mt-8 flex flex-col gap-1" aria-label="Navegação móvel">
|
||||
{NAV.map((item) => (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className="text-base font-medium text-foreground py-3 px-2 rounded-lg hover:bg-muted transition-colors"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
<div className="mt-6 flex flex-col gap-3">
|
||||
<Button asChild variant="outline" className="rounded-lg w-full">
|
||||
<Link to="/login" onClick={() => setOpen(false)}>Entrar</Link>
|
||||
</Button>
|
||||
<Button asChild className="rounded-lg w-full bg-primary hover:bg-primary-dark text-primary-foreground">
|
||||
<Link to="/signup" onClick={() => setOpen(false)}>Começar agora</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
19
src/components/mika/Logo.tsx
Normal file
19
src/components/mika/Logo.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { Link } from "@tanstack/react-router";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Logo({ className, size = "md" }: { className?: string; size?: "sm" | "md" | "lg" }) {
|
||||
const sizes = {
|
||||
sm: "text-xl",
|
||||
md: "text-2xl",
|
||||
lg: "text-3xl",
|
||||
};
|
||||
return (
|
||||
<Link
|
||||
to="/"
|
||||
aria-label="Mika — página inicial"
|
||||
className={cn("font-bold tracking-tight text-foreground", sizes[size], className)}
|
||||
>
|
||||
Mika<span className="text-accent">.</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
207
src/components/mika/PlansSection.tsx
Normal file
207
src/components/mika/PlansSection.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { EnterpriseLeadForm } from "./EnterpriseLeadForm";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Fallback estático — na Etapa 2 será substituído por query Supabase à tabela `plans`.
|
||||
const STATIC_PLANS = [
|
||||
{
|
||||
slug: "basic",
|
||||
name: "Basic",
|
||||
description: "Para começar a usar IA no dia a dia.",
|
||||
monthly: 69.9,
|
||||
yearly: 671.04,
|
||||
highlighted: false,
|
||||
is_enterprise: false,
|
||||
features: [
|
||||
"1 agente pessoal no Telegram",
|
||||
"Memória persistente básica",
|
||||
"Integração Google Workspace",
|
||||
"5 skills personalizadas",
|
||||
"Suporte por e-mail",
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "starter",
|
||||
name: "Starter",
|
||||
description: "Para quem usa IA todos os dias.",
|
||||
monthly: 199.9,
|
||||
yearly: 1919.04,
|
||||
highlighted: false,
|
||||
is_enterprise: false,
|
||||
features: [
|
||||
"Tudo do Basic",
|
||||
"Memória avançada e contextual",
|
||||
"Skills ilimitadas",
|
||||
"Agendamentos automáticos",
|
||||
"Suporte prioritário",
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "professional",
|
||||
name: "Professional",
|
||||
description: "Para profissionais e times pequenos.",
|
||||
monthly: 399.9,
|
||||
yearly: 3839.04,
|
||||
highlighted: true,
|
||||
is_enterprise: false,
|
||||
features: [
|
||||
"Tudo do Starter",
|
||||
"VPS dedicada de alta performance",
|
||||
"Modelos de IA premium",
|
||||
"Integrações personalizadas",
|
||||
"Onboarding 1:1",
|
||||
"Suporte em horário estendido",
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "enterprise",
|
||||
name: "Enterprise",
|
||||
description: "Para empresas com necessidades específicas.",
|
||||
monthly: null,
|
||||
yearly: null,
|
||||
highlighted: false,
|
||||
is_enterprise: true,
|
||||
features: [
|
||||
"Tudo do Professional",
|
||||
"Múltiplos agentes",
|
||||
"SSO e gestão de equipe",
|
||||
"SLA contratual",
|
||||
"Treinamento da equipe",
|
||||
"Account manager dedicado",
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
const fmtBRL = (v: number) =>
|
||||
v.toLocaleString("pt-BR", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
export function PlansSection() {
|
||||
const [yearly, setYearly] = useState(false);
|
||||
|
||||
return (
|
||||
<section id="planos" className="py-20 sm:py-28 bg-background">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold tracking-tight">Planos para cada estágio</h2>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
Sem fidelidade. Cancele quando quiser. Reembolso integral nos primeiros 7 dias.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 inline-flex items-center gap-3 bg-muted rounded-full px-4 py-2">
|
||||
<span className={cn("text-sm font-medium", !yearly && "text-foreground", yearly && "text-muted-foreground")}>
|
||||
Mensal
|
||||
</span>
|
||||
<Switch checked={yearly} onCheckedChange={setYearly} aria-label="Alternar entre mensal e anual" />
|
||||
<span className={cn("text-sm font-medium flex items-center gap-2", yearly && "text-foreground", !yearly && "text-muted-foreground")}>
|
||||
Anual
|
||||
<Badge className="bg-secondary/15 text-secondary hover:bg-secondary/15 border-0 font-semibold">20% off</Badge>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-14 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 items-stretch">
|
||||
{STATIC_PLANS.map((plan) => {
|
||||
const price = yearly ? plan.yearly : plan.monthly;
|
||||
const monthlyEquivalent = yearly && plan.yearly ? plan.yearly / 12 : null;
|
||||
return (
|
||||
<article
|
||||
key={plan.slug}
|
||||
className={cn(
|
||||
"relative flex flex-col rounded-xl border bg-card p-6 shadow-soft transition-all",
|
||||
plan.highlighted
|
||||
? "border-2 border-primary shadow-glow lg:scale-[1.03]"
|
||||
: "border-border hover:border-primary/30 hover:shadow-lg",
|
||||
)}
|
||||
>
|
||||
{plan.highlighted && (
|
||||
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 bg-accent text-accent-foreground hover:bg-accent border-0 font-semibold">
|
||||
Mais popular
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl font-bold">{plan.name}</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{plan.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 min-h-[88px]">
|
||||
{plan.is_enterprise ? (
|
||||
<p className="text-3xl font-bold">Sob consulta</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-sm text-muted-foreground">R$</span>
|
||||
<span className="text-4xl font-bold tracking-tight">
|
||||
{fmtBRL(yearly && monthlyEquivalent ? monthlyEquivalent : (price as number))}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">/mês</span>
|
||||
</div>
|
||||
{yearly && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
R$ {fmtBRL(plan.yearly!)} cobrados anualmente
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="mt-6 space-y-3 flex-1">
|
||||
{plan.features.map((f) => (
|
||||
<li key={f} className="flex items-start gap-2 text-sm">
|
||||
<Check className="h-4 w-4 text-secondary mt-0.5 flex-shrink-0" aria-hidden />
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-6">
|
||||
{plan.is_enterprise ? (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="w-full rounded-lg">Falar com vendas</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Plano Enterprise</DialogTitle>
|
||||
<DialogDescription>
|
||||
Conte um pouco sobre sua empresa. Nosso time entra em contato em até 1 dia útil.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<EnterpriseLeadForm />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Button
|
||||
asChild
|
||||
className={cn(
|
||||
"w-full rounded-lg transition-all duration-150 active:scale-[0.98]",
|
||||
plan.highlighted
|
||||
? "bg-primary hover:bg-primary-dark text-primary-foreground"
|
||||
: "bg-foreground hover:bg-foreground/90 text-background",
|
||||
)}
|
||||
>
|
||||
<Link to="/signup" search={{ plan: plan.slug, cycle: yearly ? "yearly" : "monthly" } as never}>
|
||||
Assinar agora
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<p className="mt-3 text-xs text-muted-foreground text-center">
|
||||
Sujeito à política de uso justo. Veja termos.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
21
src/components/mika/ThemeToggle.tsx
Normal file
21
src/components/mika/ThemeToggle.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"use client";
|
||||
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={theme === "dark" ? "Mudar para tema claro" : "Mudar para tema escuro"}
|
||||
onClick={toggleTheme}
|
||||
className="rounded-lg"
|
||||
>
|
||||
<Sun className="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
65
src/components/theme-provider.tsx
Normal file
65
src/components/theme-provider.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
type ThemeContextValue = {
|
||||
theme: Theme;
|
||||
setTheme: (t: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
const STORAGE_KEY = "mika-theme";
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
if (typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
if (theme === "dark") root.classList.add("dark");
|
||||
else root.classList.remove("dark");
|
||||
root.style.colorScheme = theme;
|
||||
}
|
||||
|
||||
function getInitialTheme(): Theme {
|
||||
if (typeof window === "undefined") return "light";
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "light" || stored === "dark") return stored;
|
||||
} catch {}
|
||||
if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) return "dark";
|
||||
return "light";
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>("light");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const t = getInitialTheme();
|
||||
setThemeState(t);
|
||||
applyTheme(t);
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const setTheme = (t: Theme) => {
|
||||
setThemeState(t);
|
||||
applyTheme(t);
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, t);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const toggleTheme = () => setTheme(theme === "dark" ? "light" : "dark");
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme, toggleTheme }}>
|
||||
<div style={{ visibility: mounted ? "visible" : "visible" }}>{children}</div>
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error("useTheme deve ser usado dentro de ThemeProvider");
|
||||
return ctx;
|
||||
}
|
||||
77
src/integrations/supabase/auth-middleware.ts
Normal file
77
src/integrations/supabase/auth-middleware.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
// This file is automatically generated. Do not edit it directly.
|
||||
import { createMiddleware } from '@tanstack/react-start'
|
||||
import { getRequest } from '@tanstack/react-start/server'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import type { Database } from './types'
|
||||
|
||||
|
||||
|
||||
export const requireSupabaseAuth = createMiddleware({ type: 'function' }).server(
|
||||
async ({ next }) => {
|
||||
|
||||
const SUPABASE_URL = process.env.SUPABASE_URL;
|
||||
const SUPABASE_PUBLISHABLE_KEY = process.env.SUPABASE_PUBLISHABLE_KEY;
|
||||
|
||||
if (!SUPABASE_URL || !SUPABASE_PUBLISHABLE_KEY) {
|
||||
throw new Response(
|
||||
'Missing Supabase environment variables. Ensure SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY are set.',
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const request = getRequest();
|
||||
|
||||
if (!request?.headers) {
|
||||
throw new Response('Unauthorized: No request headers available', { status: 401 });
|
||||
}
|
||||
|
||||
const authHeader = request.headers.get('authorization');
|
||||
|
||||
if (!authHeader) {
|
||||
throw new Response('Unauthorized: No authorization header provided', { status: 401 });
|
||||
}
|
||||
|
||||
if (!authHeader.startsWith('Bearer ')) {
|
||||
throw new Response('Unauthorized: Only Bearer tokens are supported', { status: 401 });
|
||||
}
|
||||
|
||||
const token = authHeader.replace('Bearer ', '');
|
||||
if (!token) {
|
||||
throw new Response('Unauthorized: No token provided', { status: 401 });
|
||||
}
|
||||
|
||||
const supabase = createClient<Database>(
|
||||
SUPABASE_URL!,
|
||||
SUPABASE_PUBLISHABLE_KEY!,
|
||||
{
|
||||
global: {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
storage: undefined,
|
||||
persistSession: false,
|
||||
autoRefreshToken: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const { data, error } = await supabase.auth.getClaims(token);
|
||||
if (error || !data?.claims) {
|
||||
throw new Response('Unauthorized: Invalid token', { status: 401 });
|
||||
}
|
||||
|
||||
if (!data.claims.sub) {
|
||||
throw new Response('Unauthorized: No user ID found in token', { status: 401 });
|
||||
}
|
||||
|
||||
return next({
|
||||
context: {
|
||||
supabase,
|
||||
userId: data.claims.sub,
|
||||
claims: data.claims,
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
37
src/integrations/supabase/client.server.ts
Normal file
37
src/integrations/supabase/client.server.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// This file is automatically generated. Do not edit it directly.
|
||||
// Server-side Supabase client with service role key - bypasses RLS.
|
||||
// Use this for admin operations in server functions and server routes only.
|
||||
// For user-authenticated queries (with RLS), use the auth middleware instead.
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import type { Database } from './types';
|
||||
|
||||
function createSupabaseAdminClient() {
|
||||
const SUPABASE_URL = process.env.SUPABASE_URL;
|
||||
const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
|
||||
throw new Error(
|
||||
'Missing Supabase server environment variables. Ensure SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY are set.'
|
||||
);
|
||||
}
|
||||
|
||||
return createClient<Database>(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, {
|
||||
auth: {
|
||||
storage: undefined,
|
||||
persistSession: false,
|
||||
autoRefreshToken: false,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let _supabaseAdmin: ReturnType<typeof createSupabaseAdminClient> | undefined;
|
||||
|
||||
// Server-side Supabase client with service role - bypasses RLS
|
||||
// SECURITY: Only use this for trusted server-side operations, never expose to client code
|
||||
// Import like: import { supabaseAdmin } from "@/integrations/supabase/client.server";
|
||||
export const supabaseAdmin = new Proxy({} as ReturnType<typeof createSupabaseAdminClient>, {
|
||||
get(_, prop, receiver) {
|
||||
if (!_supabaseAdmin) _supabaseAdmin = createSupabaseAdminClient();
|
||||
return Reflect.get(_supabaseAdmin, prop, receiver);
|
||||
},
|
||||
});
|
||||
36
src/integrations/supabase/client.ts
Normal file
36
src/integrations/supabase/client.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// This file is automatically generated. Do not edit it directly.
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import type { Database } from './types';
|
||||
|
||||
function createSupabaseClient() {
|
||||
// Use import.meta.env for client-side (Vite build-time replacement)
|
||||
// Fall back to process.env for SSR (server-side rendering)
|
||||
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
|
||||
const SUPABASE_PUBLISHABLE_KEY = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY || process.env.SUPABASE_PUBLISHABLE_KEY;
|
||||
|
||||
if (!SUPABASE_URL || !SUPABASE_PUBLISHABLE_KEY) {
|
||||
throw new Error(
|
||||
'Missing Supabase environment variables. Ensure SUPABASE_URL and SUPABASE_PUBLISHABLE_KEY (or VITE_ prefixed versions) are set in your .env file.'
|
||||
);
|
||||
}
|
||||
|
||||
return createClient<Database>(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY, {
|
||||
auth: {
|
||||
storage: typeof window !== 'undefined' ? localStorage : undefined,
|
||||
persistSession: true,
|
||||
autoRefreshToken: true,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let _supabase: ReturnType<typeof createSupabaseClient> | undefined;
|
||||
|
||||
// Import the supabase client like this:
|
||||
// import { supabase } from "@/integrations/supabase/client";
|
||||
export const supabase = new Proxy({} as ReturnType<typeof createSupabaseClient>, {
|
||||
get(_, prop, receiver) {
|
||||
if (!_supabase) _supabase = createSupabaseClient();
|
||||
return Reflect.get(_supabase, prop, receiver);
|
||||
},
|
||||
});
|
||||
|
||||
155
src/integrations/supabase/types.ts
Normal file
155
src/integrations/supabase/types.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
export type Json =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| { [key: string]: Json | undefined }
|
||||
| Json[]
|
||||
|
||||
export type Database = {
|
||||
// Allows to automatically instantiate createClient with right options
|
||||
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
|
||||
__InternalSupabase: {
|
||||
PostgrestVersion: "14.5"
|
||||
}
|
||||
public: {
|
||||
Tables: {
|
||||
[_ in never]: never
|
||||
}
|
||||
Views: {
|
||||
[_ in never]: never
|
||||
}
|
||||
Functions: {
|
||||
[_ in never]: never
|
||||
}
|
||||
Enums: {
|
||||
[_ in never]: never
|
||||
}
|
||||
CompositeTypes: {
|
||||
[_ in never]: never
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type DatabaseWithoutInternals = Omit<Database, "__InternalSupabase">
|
||||
|
||||
type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, "public">]
|
||||
|
||||
export type Tables<
|
||||
DefaultSchemaTableNameOrOptions extends
|
||||
| keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
|
||||
| { schema: keyof DatabaseWithoutInternals },
|
||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||
schema: keyof DatabaseWithoutInternals
|
||||
}
|
||||
? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
|
||||
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
|
||||
: never = never,
|
||||
> = DefaultSchemaTableNameOrOptions extends {
|
||||
schema: keyof DatabaseWithoutInternals
|
||||
}
|
||||
? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
|
||||
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
|
||||
Row: infer R
|
||||
}
|
||||
? R
|
||||
: never
|
||||
: DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
|
||||
DefaultSchema["Views"])
|
||||
? (DefaultSchema["Tables"] &
|
||||
DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
|
||||
Row: infer R
|
||||
}
|
||||
? R
|
||||
: never
|
||||
: never
|
||||
|
||||
export type TablesInsert<
|
||||
DefaultSchemaTableNameOrOptions extends
|
||||
| keyof DefaultSchema["Tables"]
|
||||
| { schema: keyof DatabaseWithoutInternals },
|
||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||
schema: keyof DatabaseWithoutInternals
|
||||
}
|
||||
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
|
||||
: never = never,
|
||||
> = DefaultSchemaTableNameOrOptions extends {
|
||||
schema: keyof DatabaseWithoutInternals
|
||||
}
|
||||
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
||||
Insert: infer I
|
||||
}
|
||||
? I
|
||||
: never
|
||||
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
|
||||
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
|
||||
Insert: infer I
|
||||
}
|
||||
? I
|
||||
: never
|
||||
: never
|
||||
|
||||
export type TablesUpdate<
|
||||
DefaultSchemaTableNameOrOptions extends
|
||||
| keyof DefaultSchema["Tables"]
|
||||
| { schema: keyof DatabaseWithoutInternals },
|
||||
TableName extends DefaultSchemaTableNameOrOptions extends {
|
||||
schema: keyof DatabaseWithoutInternals
|
||||
}
|
||||
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
|
||||
: never = never,
|
||||
> = DefaultSchemaTableNameOrOptions extends {
|
||||
schema: keyof DatabaseWithoutInternals
|
||||
}
|
||||
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
|
||||
Update: infer U
|
||||
}
|
||||
? U
|
||||
: never
|
||||
: DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
|
||||
? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
|
||||
Update: infer U
|
||||
}
|
||||
? U
|
||||
: never
|
||||
: never
|
||||
|
||||
export type Enums<
|
||||
DefaultSchemaEnumNameOrOptions extends
|
||||
| keyof DefaultSchema["Enums"]
|
||||
| { schema: keyof DatabaseWithoutInternals },
|
||||
EnumName extends DefaultSchemaEnumNameOrOptions extends {
|
||||
schema: keyof DatabaseWithoutInternals
|
||||
}
|
||||
? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
|
||||
: never = never,
|
||||
> = DefaultSchemaEnumNameOrOptions extends {
|
||||
schema: keyof DatabaseWithoutInternals
|
||||
}
|
||||
? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
|
||||
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
|
||||
? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
|
||||
: never
|
||||
|
||||
export type CompositeTypes<
|
||||
PublicCompositeTypeNameOrOptions extends
|
||||
| keyof DefaultSchema["CompositeTypes"]
|
||||
| { schema: keyof DatabaseWithoutInternals },
|
||||
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
|
||||
schema: keyof DatabaseWithoutInternals
|
||||
}
|
||||
? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
|
||||
: never = never,
|
||||
> = PublicCompositeTypeNameOrOptions extends {
|
||||
schema: keyof DatabaseWithoutInternals
|
||||
}
|
||||
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
|
||||
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
|
||||
? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
|
||||
: never
|
||||
|
||||
export const Constants = {
|
||||
public: {
|
||||
Enums: {},
|
||||
},
|
||||
} as const
|
||||
|
|
@ -9,8 +9,20 @@
|
|||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as SignupRouteImport } from './routes/signup'
|
||||
import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
|
||||
const SignupRoute = SignupRouteImport.update({
|
||||
id: '/signup',
|
||||
path: '/signup',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LoginRoute = LoginRouteImport.update({
|
||||
id: '/login',
|
||||
path: '/login',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
|
|
@ -19,28 +31,50 @@ const IndexRoute = IndexRouteImport.update({
|
|||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/signup': typeof SignupRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/signup': typeof SignupRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/signup': typeof SignupRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/'
|
||||
fullPaths: '/' | '/login' | '/signup'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/'
|
||||
id: '__root__' | '/'
|
||||
to: '/' | '/login' | '/signup'
|
||||
id: '__root__' | '/' | '/login' | '/signup'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
LoginRoute: typeof LoginRoute
|
||||
SignupRoute: typeof SignupRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/signup': {
|
||||
id: '/signup'
|
||||
path: '/signup'
|
||||
fullPath: '/signup'
|
||||
preLoaderRoute: typeof SignupRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/login': {
|
||||
id: '/login'
|
||||
path: '/login'
|
||||
fullPath: '/login'
|
||||
preLoaderRoute: typeof LoginRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
|
|
@ -53,16 +87,9 @@ declare module '@tanstack/react-router' {
|
|||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
LoginRoute: LoginRoute,
|
||||
SignupRoute: SignupRoute,
|
||||
}
|
||||
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>>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { Outlet, Link, createRootRoute, HeadContent, Scripts } from "@tanstack/react-router";
|
||||
import { Outlet, createRootRoute, HeadContent, Scripts } from "@tanstack/react-router";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
|
||||
import appCss from "../styles.css?url";
|
||||
|
||||
|
|
@ -7,17 +9,17 @@ function NotFoundComponent() {
|
|||
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||
<div className="max-w-md text-center">
|
||||
<h1 className="text-7xl font-bold text-foreground">404</h1>
|
||||
<h2 className="mt-4 text-xl font-semibold text-foreground">Page not found</h2>
|
||||
<h2 className="mt-4 text-xl font-semibold text-foreground">Página não encontrada</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
A página que você procura não existe ou foi movida.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
<a
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary-dark"
|
||||
>
|
||||
Go home
|
||||
</Link>
|
||||
Ir para o início
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -29,19 +31,40 @@ export const Route = createRootRoute({
|
|||
meta: [
|
||||
{ charSet: "utf-8" },
|
||||
{ name: "viewport", content: "width=device-width, initial-scale=1" },
|
||||
{ title: "Lovable App" },
|
||||
{ name: "description", content: "Lovable Generated Project" },
|
||||
{ name: "author", content: "Lovable" },
|
||||
{ property: "og:title", content: "Lovable App" },
|
||||
{ property: "og:description", content: "Lovable Generated Project" },
|
||||
{ title: "Mika — Seu assistente de IA pessoal no Telegram" },
|
||||
{
|
||||
name: "description",
|
||||
content:
|
||||
"Agentes de IA personalizados que aprendem com você e te atendem direto no Telegram. Planos a partir de R$ 69,90/mês.",
|
||||
},
|
||||
{ name: "author", content: "DOMCO" },
|
||||
{ name: "theme-color", content: "#6366F1" },
|
||||
{ property: "og:title", content: "Mika — Seu assistente de IA pessoal no Telegram" },
|
||||
{
|
||||
property: "og:description",
|
||||
content:
|
||||
"Agentes de IA personalizados que aprendem com você e te atendem direto no Telegram. Planos a partir de R$ 69,90/mês.",
|
||||
},
|
||||
{ property: "og:type", content: "website" },
|
||||
{ name: "twitter:card", content: "summary" },
|
||||
{ name: "twitter:site", content: "@Lovable" },
|
||||
{ property: "og:locale", content: "pt_BR" },
|
||||
{ property: "og:url", content: "https://mika.domco.ai" },
|
||||
{ property: "og:image", content: "https://mika.domco.ai/og.svg" },
|
||||
{ name: "twitter:card", content: "summary_large_image" },
|
||||
{ name: "twitter:title", content: "Mika — Seu assistente de IA pessoal no Telegram" },
|
||||
{
|
||||
name: "twitter:description",
|
||||
content: "Agentes de IA personalizados, em português, direto no Telegram.",
|
||||
},
|
||||
{ name: "twitter:image", content: "https://mika.domco.ai/og.svg" },
|
||||
],
|
||||
links: [
|
||||
{ rel: "stylesheet", href: appCss },
|
||||
{ rel: "icon", href: "/favicon.svg", type: "image/svg+xml" },
|
||||
{ rel: "canonical", href: "https://mika.domco.ai" },
|
||||
{ rel: "preconnect", href: "https://rsms.me" },
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: appCss,
|
||||
href: "https://rsms.me/inter/inter.css",
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
|
@ -52,7 +75,7 @@ export const Route = createRootRoute({
|
|||
|
||||
function RootShell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
|
|
@ -65,5 +88,10 @@ function RootShell({ children }: { children: React.ReactNode }) {
|
|||
}
|
||||
|
||||
function RootComponent() {
|
||||
return <Outlet />;
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<Outlet />
|
||||
<Toaster richColors position="top-right" />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,28 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { LandingHeader } from "@/components/mika/LandingHeader";
|
||||
import { HeroSection } from "@/components/mika/HeroSection";
|
||||
import { FeaturesSection } from "@/components/mika/FeaturesSection";
|
||||
import { HowItWorksSection } from "@/components/mika/HowItWorksSection";
|
||||
import { PlansSection } from "@/components/mika/PlansSection";
|
||||
import { FaqSection } from "@/components/mika/FaqSection";
|
||||
import { LandingFooter } from "@/components/mika/LandingFooter";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: Index,
|
||||
component: LandingPage,
|
||||
});
|
||||
|
||||
// IMPORTANT: Replace this placeholder. For sites with multiple pages (About, Services, Contact, etc.),
|
||||
// create separate route files (about.tsx, services.tsx, contact.tsx) — don't put all pages in this file.
|
||||
function PlaceholderIndex() {
|
||||
function LandingPage() {
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-screen items-center justify-center"
|
||||
style={{ backgroundColor: "#fcfbf8" }}
|
||||
>
|
||||
<img
|
||||
data-lovable-blank-page-placeholder="REMOVE_THIS"
|
||||
src="https://cdn.gpteng.co/blank-app-v1.svg"
|
||||
alt="Your app will live here!"
|
||||
/>
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<LandingHeader />
|
||||
<main>
|
||||
<HeroSection />
|
||||
<FeaturesSection />
|
||||
<HowItWorksSection />
|
||||
<PlansSection />
|
||||
<FaqSection />
|
||||
</main>
|
||||
<LandingFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Index() {
|
||||
return <PlaceholderIndex />;
|
||||
}
|
||||
|
|
|
|||
21
src/routes/login.tsx
Normal file
21
src/routes/login.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { Logo } from "@/components/mika/Logo";
|
||||
|
||||
export const Route = createFileRoute("/login")({
|
||||
component: LoginPlaceholder,
|
||||
});
|
||||
|
||||
function LoginPlaceholder() {
|
||||
return (
|
||||
<div className="min-h-screen grid place-items-center bg-background px-4">
|
||||
<div className="max-w-md text-center space-y-4">
|
||||
<Logo size="lg" />
|
||||
<h1 className="text-2xl font-bold">Login</h1>
|
||||
<p className="text-muted-foreground">
|
||||
A página de login completa será implementada na Etapa 2 (Auth + Supabase).
|
||||
</p>
|
||||
<Link to="/" className="inline-block text-primary hover:underline">← Voltar para o início</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/routes/signup.tsx
Normal file
38
src/routes/signup.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { createFileRoute, Link } from "@tanstack/react-router";
|
||||
import { Logo } from "@/components/mika/Logo";
|
||||
|
||||
type SignupSearch = {
|
||||
plan?: string;
|
||||
cycle?: "monthly" | "yearly";
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/signup")({
|
||||
validateSearch: (search: Record<string, unknown>): SignupSearch => ({
|
||||
plan: typeof search.plan === "string" ? search.plan : undefined,
|
||||
cycle: search.cycle === "yearly" || search.cycle === "monthly" ? search.cycle : undefined,
|
||||
}),
|
||||
component: SignupPlaceholder,
|
||||
});
|
||||
|
||||
function SignupPlaceholder() {
|
||||
const { plan, cycle } = Route.useSearch();
|
||||
return (
|
||||
<div className="min-h-screen grid place-items-center bg-background px-4">
|
||||
<div className="max-w-md text-center space-y-4">
|
||||
<Logo size="lg" />
|
||||
<h1 className="text-2xl font-bold">Criar conta</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Cadastro completo será implementado na Etapa 2.
|
||||
</p>
|
||||
{plan && (
|
||||
<p className="text-sm bg-primary/10 text-primary rounded-lg px-3 py-2 inline-block">
|
||||
Plano selecionado: <strong>{plan}</strong> · {cycle === "yearly" ? "anual" : "mensal"}
|
||||
</p>
|
||||
)}
|
||||
<div>
|
||||
<Link to="/" className="inline-block text-primary hover:underline">← Voltar para o início</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
227
src/styles.css
227
src/styles.css
|
|
@ -5,47 +5,48 @@
|
|||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/*
|
||||
* Design system definition.
|
||||
*
|
||||
* The @theme inline block maps CSS custom properties to Tailwind utility
|
||||
* classes (e.g. --color-primary -> bg-primary, text-primary).
|
||||
*
|
||||
* The :root and .dark blocks define the actual color values using oklch.
|
||||
* All colors MUST use oklch format.
|
||||
*
|
||||
* To add a new semantic color:
|
||||
* 1. Add the variable to :root (light value) and .dark (dark value)
|
||||
* 2. Register it in @theme inline as --color-<name>: var(--<name>)
|
||||
* Mika Design System
|
||||
* Paleta inspirada em Linear / Supabase / Cal.com.
|
||||
* - Primary: Indigo (#6366F1) — confiança e tecnologia
|
||||
* - Secondary: Emerald (#10B981) — sucesso/check
|
||||
* - Accent: Amber (#F59E0B) — destaque, ponto final do wordmark
|
||||
* Todas as cores em oklch.
|
||||
*/
|
||||
|
||||
@theme inline {
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-surface: var(--surface);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary-dark: var(--primary-dark);
|
||||
--color-primary-light: var(--primary-light);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-success: var(--success);
|
||||
--color-warning: var(--warning);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-ring-offset-background: var(--background);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
|
|
@ -63,73 +64,115 @@
|
|||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.129 0.042 264.695);
|
||||
|
||||
/* Mika light tokens */
|
||||
--background: oklch(0.985 0 0); /* #FAFAFA */
|
||||
--foreground: oklch(0.18 0.04 265); /* #0F172A */
|
||||
--surface: oklch(1 0 0); /* #FFFFFF */
|
||||
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.129 0.042 264.695);
|
||||
--card-foreground: oklch(0.18 0.04 265);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.129 0.042 264.695);
|
||||
--primary: oklch(0.208 0.042 265.755);
|
||||
--primary-foreground: oklch(0.984 0.003 247.858);
|
||||
--secondary: oklch(0.968 0.007 247.896);
|
||||
--secondary-foreground: oklch(0.208 0.042 265.755);
|
||||
--muted: oklch(0.968 0.007 247.896);
|
||||
--muted-foreground: oklch(0.554 0.046 257.417);
|
||||
--accent: oklch(0.968 0.007 247.896);
|
||||
--accent-foreground: oklch(0.208 0.042 265.755);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.984 0.003 247.858);
|
||||
--border: oklch(0.929 0.013 255.508);
|
||||
--input: oklch(0.929 0.013 255.508);
|
||||
--ring: oklch(0.704 0.04 256.788);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.984 0.003 247.858);
|
||||
--sidebar-foreground: oklch(0.129 0.042 264.695);
|
||||
--sidebar-primary: oklch(0.208 0.042 265.755);
|
||||
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-accent: oklch(0.968 0.007 247.896);
|
||||
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
|
||||
--sidebar-border: oklch(0.929 0.013 255.508);
|
||||
--sidebar-ring: oklch(0.704 0.04 256.788);
|
||||
--popover-foreground: oklch(0.18 0.04 265);
|
||||
|
||||
--primary: oklch(0.62 0.19 277); /* #6366F1 indigo-500 */
|
||||
--primary-foreground: oklch(0.99 0 0);
|
||||
--primary-dark: oklch(0.55 0.21 277); /* #4F46E5 indigo-600 */
|
||||
--primary-light: oklch(0.74 0.13 277); /* #818CF8 indigo-400 */
|
||||
|
||||
--secondary: oklch(0.72 0.17 162); /* #10B981 emerald-500 */
|
||||
--secondary-foreground: oklch(0.99 0 0);
|
||||
|
||||
--accent: oklch(0.78 0.16 75); /* #F59E0B amber-500 */
|
||||
--accent-foreground: oklch(0.18 0.04 265);
|
||||
|
||||
--muted: oklch(0.96 0.005 256);
|
||||
--muted-foreground: oklch(0.55 0.03 256);/* #64748B */
|
||||
|
||||
--destructive: oklch(0.63 0.23 25); /* #EF4444 */
|
||||
--destructive-foreground: oklch(0.99 0 0);
|
||||
--success: oklch(0.72 0.17 162);
|
||||
--warning: oklch(0.78 0.16 75);
|
||||
|
||||
--border: oklch(0.92 0.01 256); /* #E2E8F0 */
|
||||
--input: oklch(0.92 0.01 256);
|
||||
--ring: oklch(0.62 0.19 277);
|
||||
|
||||
--chart-1: oklch(0.62 0.19 277);
|
||||
--chart-2: oklch(0.72 0.17 162);
|
||||
--chart-3: oklch(0.78 0.16 75);
|
||||
--chart-4: oklch(0.55 0.21 277);
|
||||
--chart-5: oklch(0.74 0.13 277);
|
||||
|
||||
--sidebar: oklch(1 0 0);
|
||||
--sidebar-foreground: oklch(0.18 0.04 265);
|
||||
--sidebar-primary: oklch(0.62 0.19 277);
|
||||
--sidebar-primary-foreground: oklch(0.99 0 0);
|
||||
--sidebar-accent: oklch(0.96 0.005 256);
|
||||
--sidebar-accent-foreground: oklch(0.18 0.04 265);
|
||||
--sidebar-border: oklch(0.92 0.01 256);
|
||||
--sidebar-ring: oklch(0.62 0.19 277);
|
||||
|
||||
/* Gradientes & sombras Mika */
|
||||
--gradient-hero: radial-gradient(circle at 30% 20%, oklch(0.62 0.19 277 / 0.18), transparent 60%),
|
||||
radial-gradient(circle at 80% 80%, oklch(0.72 0.17 162 / 0.12), transparent 55%);
|
||||
--gradient-primary: linear-gradient(135deg, oklch(0.62 0.19 277), oklch(0.55 0.21 277));
|
||||
--shadow-soft: 0 1px 2px oklch(0.18 0.04 265 / 0.04), 0 4px 12px oklch(0.18 0.04 265 / 0.06);
|
||||
--shadow-glow: 0 10px 40px -10px oklch(0.62 0.19 277 / 0.45);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.129 0.042 264.695);
|
||||
--foreground: oklch(0.984 0.003 247.858);
|
||||
--card: oklch(0.208 0.042 265.755);
|
||||
--card-foreground: oklch(0.984 0.003 247.858);
|
||||
--popover: oklch(0.208 0.042 265.755);
|
||||
--popover-foreground: oklch(0.984 0.003 247.858);
|
||||
--primary: oklch(0.929 0.013 255.508);
|
||||
--primary-foreground: oklch(0.208 0.042 265.755);
|
||||
--secondary: oklch(0.279 0.041 260.031);
|
||||
--secondary-foreground: oklch(0.984 0.003 247.858);
|
||||
--muted: oklch(0.279 0.041 260.031);
|
||||
--muted-foreground: oklch(0.704 0.04 256.788);
|
||||
--accent: oklch(0.279 0.041 260.031);
|
||||
--accent-foreground: oklch(0.984 0.003 247.858);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(0.984 0.003 247.858);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.551 0.027 264.364);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.208 0.042 265.755);
|
||||
--sidebar-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-accent: oklch(0.279 0.041 260.031);
|
||||
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.551 0.027 264.364);
|
||||
--background: oklch(0.18 0.04 265); /* #0F172A */
|
||||
--foreground: oklch(0.96 0.01 247); /* #F1F5F9 */
|
||||
--surface: oklch(0.25 0.04 260); /* #1E293B */
|
||||
|
||||
--card: oklch(0.25 0.04 260);
|
||||
--card-foreground: oklch(0.96 0.01 247);
|
||||
--popover: oklch(0.25 0.04 260);
|
||||
--popover-foreground: oklch(0.96 0.01 247);
|
||||
|
||||
--primary: oklch(0.62 0.19 277);
|
||||
--primary-foreground: oklch(0.99 0 0);
|
||||
--primary-dark: oklch(0.55 0.21 277);
|
||||
--primary-light: oklch(0.74 0.13 277);
|
||||
|
||||
--secondary: oklch(0.72 0.17 162);
|
||||
--secondary-foreground: oklch(0.99 0 0);
|
||||
|
||||
--accent: oklch(0.78 0.16 75);
|
||||
--accent-foreground: oklch(0.18 0.04 265);
|
||||
|
||||
--muted: oklch(0.28 0.04 260);
|
||||
--muted-foreground: oklch(0.65 0.03 256);
|
||||
|
||||
--destructive: oklch(0.63 0.23 25);
|
||||
--destructive-foreground: oklch(0.99 0 0);
|
||||
--success: oklch(0.72 0.17 162);
|
||||
--warning: oklch(0.78 0.16 75);
|
||||
|
||||
--border: oklch(0.32 0.03 256); /* #334155 */
|
||||
--input: oklch(0.32 0.03 256);
|
||||
--ring: oklch(0.74 0.13 277);
|
||||
|
||||
--chart-1: oklch(0.74 0.13 277);
|
||||
--chart-2: oklch(0.72 0.17 162);
|
||||
--chart-3: oklch(0.78 0.16 75);
|
||||
--chart-4: oklch(0.55 0.21 277);
|
||||
--chart-5: oklch(0.62 0.19 277);
|
||||
|
||||
--sidebar: oklch(0.22 0.04 262);
|
||||
--sidebar-foreground: oklch(0.96 0.01 247);
|
||||
--sidebar-primary: oklch(0.74 0.13 277);
|
||||
--sidebar-primary-foreground: oklch(0.99 0 0);
|
||||
--sidebar-accent: oklch(0.28 0.04 260);
|
||||
--sidebar-accent-foreground: oklch(0.96 0.01 247);
|
||||
--sidebar-border: oklch(0.32 0.03 256);
|
||||
--sidebar-ring: oklch(0.74 0.13 277);
|
||||
|
||||
--gradient-hero: radial-gradient(circle at 30% 20%, oklch(0.62 0.19 277 / 0.25), transparent 60%),
|
||||
radial-gradient(circle at 80% 80%, oklch(0.72 0.17 162 / 0.18), transparent 55%);
|
||||
--shadow-soft: 0 1px 2px oklch(0 0 0 / 0.3), 0 8px 24px oklch(0 0 0 / 0.35);
|
||||
--shadow-glow: 0 10px 40px -10px oklch(0.62 0.19 277 / 0.55);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
|
@ -137,8 +180,38 @@
|
|||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: oklch(0.62 0.19 277 / 0.25);
|
||||
color: var(--color-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.bg-gradient-hero {
|
||||
background-image: var(--gradient-hero);
|
||||
}
|
||||
.bg-gradient-primary {
|
||||
background-image: var(--gradient-primary);
|
||||
}
|
||||
.shadow-soft {
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
.shadow-glow {
|
||||
box-shadow: var(--shadow-glow);
|
||||
}
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
supabase/config.toml
Normal file
1
supabase/config.toml
Normal file
|
|
@ -0,0 +1 @@
|
|||
project_id = "smsarmgoirlcedmqvdgc"
|
||||
Loading…
Add table
Add a link
Reference in a new issue