feat: add ProfileCard component with tilt effect and metrics display

- Implemented ProfileCardComponent with dynamic tilt effect based on pointer and device orientation.
- Added metrics display including access counts and top applications.
- Created mock data for summary statistics and application usage.
- Introduced Navigation component for pagination control.
- Developed StorySlide component for full-screen slide presentation.
- Added Badge and Carousel components for UI enhancements.
main
guilherme 2025-12-11 10:40:56 -03:00
parent d16767e287
commit 206119cd42
14 changed files with 1906 additions and 290 deletions

View File

@ -18,5 +18,7 @@
"lib": "@/lib", "lib": "@/lib",
"hooks": "@/hooks" "hooks": "@/hooks"
}, },
"registries": {} "registries": {
"@react-bits": "https://reactbits.dev/r/{name}.json"
}
} }

73
package-lock.json generated
View File

@ -21,6 +21,8 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.26",
"lucide-react": "^0.554.0", "lucide-react": "^0.554.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-day-picker": "^9.11.1", "react-day-picker": "^9.11.1",
@ -3875,6 +3877,35 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/embla-carousel": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
"license": "MIT",
"peer": true
},
"node_modules/embla-carousel-react": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz",
"integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==",
"license": "MIT",
"dependencies": {
"embla-carousel": "8.6.0",
"embla-carousel-reactive-utils": "8.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/embla-carousel-reactive-utils": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz",
"integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==",
"license": "MIT",
"peerDependencies": {
"embla-carousel": "8.6.0"
}
},
"node_modules/enhanced-resolve": { "node_modules/enhanced-resolve": {
"version": "5.18.3", "version": "5.18.3",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz",
@ -4277,6 +4308,33 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/framer-motion": {
"version": "12.23.26",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz",
"integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.23.23",
"motion-utils": "^12.23.6",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@ -4916,6 +4974,21 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/motion-dom": {
"version": "12.23.23",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.23.6"
}
},
"node_modules/motion-utils": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
"license": "MIT"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",

View File

@ -23,6 +23,8 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.26",
"lucide-react": "^0.554.0", "lucide-react": "^0.554.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-day-picker": "^9.11.1", "react-day-picker": "^9.11.1",

View File

@ -1,298 +1,306 @@
import { import logoAImg from '@/assets/images/a-agape.png';
Card, import logoImg from '@/assets/images/agape-logo.png';
CardContent, import { motion } from 'framer-motion';
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/views/components/ui/card';
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from '@/views/components/ui/chart';
import { Progress } from '@/views/components/ui/progress';
import { TrendingUp } from 'lucide-react';
import {
Bar,
BarChart,
CartesianGrid,
Line,
LineChart,
Pie,
PieChart,
XAxis,
} from 'recharts';
import { Header } from './views/components/header';
export const description = 'A stacked bar chart with a legend'; import ProfileCard from './views/components/ProfileCard';
import {
BuildingIcon,
CalendarIcon,
CertificateIcon,
ClockIcon,
} from '@phosphor-icons/react';
import { useState } from 'react';
import { summary } from './views/components/mock/summary';
import { Navigation } from './views/components/navigation';
import { StorySlide } from './views/components/story-slide';
import { Button } from './views/components/ui/button';
import { Card } from './views/components/ui/card';
import { Badge } from './views/components/ui/badge';
export function App() { export function App() {
const stats = [ return (
{ <div className="w-full h-screen overflow-hidden">
title: 'Média de Consumo (R$)', <Retrospectiva />
value: 'R$ 0,00', </div>
description: '', );
progress: 44.6, }
}, export default function Retrospectiva() {
{ const total = 6;
title: 'Total de KM Percorridos', const [current, setCurrent] = useState(0);
value: '0 KM',
description: '',
progress: 30.6,
},
{
title: 'Viagens Realizadas no Mês',
value: '0',
description: '',
progress: 24.8,
},
];
const evolutionConfig = {
price: {
label: 'Preço',
color: '#0e233d',
},
} satisfies ChartConfig;
const evolutionData = [
{ month: 'Shell', price: 186 },
{ month: 'Ipiranga', price: 305 },
{ month: 'BR', price: 237 },
];
const bestPricesConfig = {
price: {
label: 'Preço',
color: '#0e233d',
},
} satisfies ChartConfig;
const bestPricesData = [
{ station: 'Shell', price: 5.42 },
{ station: 'Ipiranga', price: 5.35 },
{ station: 'BR', price: 5.38 },
{ station: 'Ale', price: 5.3 },
];
const vehicleStatusConfig = {
active: {
label: 'Ativos',
color: '#0e233d',
},
maintenance: {
label: 'Manutenção',
color: '#173b63',
},
inactive: {
label: 'Inativos',
color: '#154677',
},
reserved: {
label: 'Reservados',
color: '#145190',
},
} satisfies ChartConfig;
const vehicleStatusData = [
{ status: 'active', count: 45, fill: '#0e233d' },
{ status: 'maintenance', count: 12, fill: '#173b63' },
{ status: 'inactive', count: 8, fill: '#154677' },
{ status: 'reserved', count: 15, fill: '#145190' },
];
const mileageConfig = {
company: {
label: 'Frota Empresa',
color: '#0e233d',
},
rented: {
label: 'Frota Terceirizada',
color: '#173b63',
},
} satisfies ChartConfig;
const mileageData = [
{ month: 'Jan', company: 1200, rented: 800 },
{ month: 'Fev', company: 1350, rented: 750 },
{ month: 'Mar', company: 1100, rented: 900 },
{ month: 'Abr', company: 1400, rented: 600 },
{ month: 'Mai', company: 1250, rented: 850 },
{ month: 'Jun', company: 1300, rented: 700 },
];
return ( return (
<div className="p-[18px] flex flex-col gap-6"> <div className="h-screen w-full overflow-hidden bg-black">
<Header /> <Navigation total={total} current={current} setCurrent={setCurrent} />
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-2 xl:grid-cols-3"> <div className="h-full snap-y snap-mandatory overflow-y-scroll scroll-smooth">
{stats.map((stat) => ( <StorySlide className="bg-linear-to-br from-[#0e233d] via-[#173b63] to-[#145190] text-white">
<Card <div className="text-center space-y-8">
key={stat.title} <motion.div
className="hover:shadow-md transition-shadow duration-300" initial={{ scale: 0.8, opacity: 0 }}
> whileInView={{ scale: 1, opacity: 1 }}
<CardHeader className="pb-2"> viewport={{ once: true }}
<CardTitle className="text-sm font-medium text-muted-foreground"> transition={{ duration: 0.6 }}>
{stat.title} <img
</CardTitle> src={logoImg}
<div className="text-4xl font-bold">{stat.value}</div> alt="Ágape Logo"
</CardHeader> className="mx-auto w-52 mb-6"
<CardContent> />
<p className="text-xs text-muted-foreground mb-4"> </motion.div>
{stat.description}
<motion.h1
className="text-6xl md:text-7xl font-medium mb-4"
initial={{ y: 20, opacity: 0 }}
whileInView={{ y: 0, opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.2 }}>
Sua Retrospectiva
<br />
Ágape {summary.year}
</motion.h1>
<motion.p
className="text-xl md:text-2xl text-white/90 max-w-2xl mx-auto font-light"
initial={{ y: 20, opacity: 0 }}
whileInView={{ y: 0, opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.4 }}>
Um ano de conquistas, eficiência e transparência na gestão pública
</motion.p>
<motion.div
initial={{ y: 20, opacity: 0 }}
whileInView={{ y: 0, opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.6 }}>
<Button
size="lg"
className="bg-white text-[#0e233d] hover:bg-white/90 text-lg px-8 py-6">
Começar
</Button>
</motion.div>
<motion.div
className="flex items-center justify-center gap-4 text-sm text-white/70 pt-8"
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.8 }}>
<span>Transparência</span>
<span></span>
<span>Eficiência</span>
<span></span>
<span>Parceria</span>
</motion.div>
</div>
</StorySlide>
<StorySlide className="bg-linear-to-br from-slate-50 to-blue-50">
<div className="space-y-8">
<div className="text-center space-y-4">
<motion.div
initial={{ scale: 0 }}
whileInView={{ scale: 1 }}
viewport={{ once: true }}
transition={{ type: 'spring', duration: 0.6 }}>
<BuildingIcon className="h-16 w-16 mx-auto text-[#0e233d]" />
</motion.div>
<h2 className="text-4xl md:text-5xl font-bold text-[#0e233d]">
Você utilizou
</h2>
<motion.div
className="text-7xl md:text-8xl font-bold text-[#145190]"
initial={{ scale: 0.5, opacity: 0 }}
whileInView={{ scale: 1, opacity: 1 }}
viewport={{ once: true }}
transition={{ type: 'spring', duration: 0.8, delay: 0.2 }}>
{summary.numApplicationsUsed}
</motion.div>
<p className="text-2xl text-muted-foreground">
aplicações Ágape neste ano
</p> </p>
<Progress value={stat.progress} className="h-2" />
</CardContent>
</Card>
))}
</div>
<div className="grid gap-6 grid-cols-1 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Evolução do Consumo de Combustível</CardTitle>
<CardDescription>
Comparativo de preços entre os principais postos
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={evolutionConfig}>
<LineChart
accessibilityLayer
data={evolutionData}
margin={{
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent hideLabel />}
/>
<Line
dataKey="price"
type="natural"
stroke="var(--color-price)"
strokeWidth={2}
dot={{
fill: 'var(--color-price)',
}}
activeDot={{
r: 6,
}}
/>
</LineChart>
</ChartContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Postos com Melhor Preço</CardTitle>
<CardDescription>
Comparativo de preços entre os principais postos
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={bestPricesConfig}>
<BarChart accessibilityLayer data={bestPricesData}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="station"
tickLine={false}
tickMargin={10}
axisLine={false}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent hideLabel />}
/>
<Bar dataKey="price" fill="var(--color-price)" radius={8} />
</BarChart>
</ChartContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Quilometragem Percorrida</CardTitle>
<CardDescription>
Total de quilômetros percorridos nos últimos meses
</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={mileageConfig}>
<BarChart accessibilityLayer data={mileageData}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
tickMargin={10}
axisLine={false}
/>
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
<ChartLegend content={<ChartLegendContent />} />
<Bar
dataKey="company"
stackId="a"
fill="var(--color-company)"
radius={[0, 0, 4, 4]}
/>
<Bar
dataKey="rented"
stackId="a"
fill="var(--color-rented)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ChartContainer>
</CardContent>
</Card>
<Card className="flex flex-col">
<CardHeader className="items-center pb-0">
<CardTitle>Status dos Veículos</CardTitle>
<CardDescription>
Distribuição dos veículos por status de operação
</CardDescription>
</CardHeader>
<CardContent className="flex-1 pb-0">
<ChartContainer
config={vehicleStatusConfig}
className="[&_.recharts-pie-label-text]:fill-foreground mx-auto aspect-square max-h-[320px] pb-0"
>
<PieChart>
<ChartTooltip content={<ChartTooltipContent hideLabel />} />
<Pie
data={vehicleStatusData}
dataKey="count"
label
nameKey="status"
/>
</PieChart>
</ChartContainer>
</CardContent>
<CardFooter className="flex-col gap-2 text-sm">
<div className="flex items-center gap-2 leading-none font-medium">
Frota operando com 85% de disponibilidade{' '}
<TrendingUp className="h-4 w-4" />
</div> </div>
<div className="text-muted-foreground leading-none">
Status atual da frota de veículos <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 mt-12">
{summary.topApplications.map((app, index) => (
<motion.div
key={index}
className="p-6 bg-white rounded-lg shadow hover:shadow-md transition-shadow"
initial={{ y: 20, opacity: 0 }}
whileInView={{ y: 0, opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}>
<h3 className="text-xl font-semibold text-[#0e233d]">
{app.name}
</h3>
<p className="text-3xl font-bold text-[#145190] mt-2">
{app.uses} usos
</p>
</motion.div>
))}
</div> </div>
</CardFooter> </div>
</Card> </StorySlide>
<StorySlide className="bg-linear-to-br from-slate-50 to-blue-50">
<div className="space-y-8">
<div className="text-center space-y-4">
<motion.div
initial={{ scale: 0 }}
whileInView={{ scale: 1 }}
viewport={{ once: true }}
transition={{ type: 'spring', duration: 0.6 }}>
<CalendarIcon className="h-16 w-16 mx-auto text-[#0e233d]" />
</motion.div>
<h2 className="text-4xl md:text-5xl font-bold text-[#0e233d]">
Principais Ações
</h2>
<p className="text-xl text-muted-foreground">
As atividades que mais impactaram sua gestão
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{summary.topActions.map((action, index) => (
<motion.div
key={action.action}
initial={{ y: 20, opacity: 0 }}
whileInView={{ y: 0, opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: index * 0.1 }}>
<Card className="p-6 hover:shadow-lg transition-shadow h-full">
<div className="flex flex-col h-full justify-between">
<div>
<div className="text-5xl font-bold text-[#145190] mb-2">
{index + 1}
</div>
<h3 className="font-semibold text-lg mb-2">
{action.action}
</h3>
</div>
<div className="text-3xl font-bold text-[#0e233d] mt-4">
{action.count.toLocaleString('pt-BR')}
</div>
</div>
</Card>
</motion.div>
))}
</div>
</div>
</StorySlide>
<StorySlide className="bg-linear-to-br from-[#0e233d] via-[#173b63] to-[#145190] text-white">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center min-h-screen p-8">
<motion.div
className="space-y-8 text-center lg:text-left"
initial={{ x: -50, opacity: 0 }}
whileInView={{ x: 0, opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6 }}>
<motion.div
initial={{ scale: 0 }}
whileInView={{ scale: 1 }}
viewport={{ once: true }}
transition={{ type: 'spring', duration: 0.6 }}>
<ClockIcon className="h-24 w-24 mb-8 text-yellow-300 lg:mx-0" />
</motion.div>
<div className="space-y-6">
<h2 className="text-4xl md:text-5xl font-bold">
Seu horário preferido
</h2>
<motion.div
className="text-6xl md:text-7xl font-bold text-yellow-300"
initial={{ scale: 0.5, opacity: 0 }}
whileInView={{ scale: 1, opacity: 1 }}
viewport={{ once: true }}
transition={{ type: 'spring', duration: 0.8, delay: 0.2 }}>
{summary.favoriteHourRange}
</motion.div>
<p className="text-xl text-white/90 max-w-lg">
É quando você mais acessa as plataformas Ágape
</p>
</div>
</motion.div>
<motion.div
className="flex justify-center lg:justify-end"
initial={{ x: 50, opacity: 0 }}
whileInView={{ x: 0, opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.2 }}>
<Card className="bg-white/10 backdrop-blur border-white/20 text-white p-8 max-w-md w-full">
<CertificateIcon className="h-14 w-14 mx-auto mb-6 text-yellow-300" />
<h3 className="text-2xl font-bold mb-4 text-center">
{summary.badge.title}
</h3>
<div className="flex justify-center mb-4">
<Badge
variant="secondary"
className="bg-yellow-300 text-[#0e233d] px-4 py-2 text-lg">
{summary.badge.subtitle}
</Badge>
</div>
<p className="text-white/90 text-center text-lg">
{summary.badge.description}
</p>
</Card>
</motion.div>
</div>
</StorySlide>
<StorySlide className="bg-linear-to-br from-slate-50 to-blue-50">
<div className="h-screen max-h-screen overflow-y-auto flex flex-col md:flex-row items-center justify-center px-6 gap-12">
<div className="space-y-12 w-full md:w-1/2 text-center md:text-left">
<motion.div
initial={{ scale: 0 }}
whileInView={{ scale: 1 }}
viewport={{ once: true }}
transition={{ type: 'spring', duration: 0.6 }}>
<img src={logoAImg} className="w-10" />
</motion.div>
<h2 className="text-4xl md:text-5xl font-bold text-[#0e233d]">
A Ágape deseja a você um {summary.year + 1} repleto de sucesso!
</h2>
<p className="text-xl text-muted-foreground max-w-xl">
{summary.messageFinal}
</p>
<motion.div
className="text-sm text-muted-foreground"
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ duration: 0.6, delay: 0.4 }}>
<p>
&copy; {summary.year} {summary.orgName}
</p>
<p className="mt-2">O Futuro da Gestão Pública começa aqui!</p>
</motion.div>
</div>
<div className="w-full md:w-1/2 flex justify-center">
<div className="max-w-md w-full">
<ProfileCard
name="Guilherme Santos"
title="Software Engineer"
handle="javicodes"
status="Online"
contactText="Contact Me"
avatarUrl="https://github.com/GuilhermeSantosUI.png"
showUserInfo={true}
enableTilt={true}
enableMobileTilt={false}
onContactClick={() => console.log('Contact clicked')}
/>
</div>
</div>
</div>
</StorySlide>
</div> </div>
</div> </div>
); );

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

View File

@ -0,0 +1,726 @@
:root {
--pointer-x: 50%;
--pointer-y: 50%;
--pointer-from-center: 0;
--pointer-from-top: 0.5;
--pointer-from-left: 0.5;
--card-opacity: 0;
--rotate-x: 0deg;
--rotate-y: 0deg;
--background-x: 50%;
--background-y: 50%;
--grain: none;
--icon: none;
--behind-gradient: none;
--behind-glow-color: rgba(125, 190, 255, 0.67);
--behind-glow-size: 25%;
--inner-gradient: none;
--sunpillar-1: hsl(2, 100%, 73%);
--sunpillar-2: hsl(53, 100%, 69%);
--sunpillar-3: hsl(93, 100%, 69%);
--sunpillar-4: hsl(176, 100%, 76%);
--sunpillar-5: hsl(228, 100%, 74%);
--sunpillar-6: hsl(283, 100%, 73%);
--sunpillar-clr-1: var(--sunpillar-1);
--sunpillar-clr-2: var(--sunpillar-2);
--sunpillar-clr-3: var(--sunpillar-3);
--sunpillar-clr-4: var(--sunpillar-4);
--sunpillar-clr-5: var(--sunpillar-5);
--sunpillar-clr-6: var(--sunpillar-6);
--card-radius: 30px;
}
.pc-card-wrapper {
perspective: 500px;
transform: translate3d(0, 0, 0.1px);
position: relative;
touch-action: none;
}
.pc-behind {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
background: radial-gradient(
circle at var(--pointer-x) var(--pointer-y),
var(--behind-glow-color) 0%,
transparent var(--behind-glow-size)
);
filter: blur(50px) saturate(1.1);
opacity: calc(0.8 * var(--card-opacity));
transition: opacity 200ms ease;
}
.pc-card-wrapper:hover,
.pc-card-wrapper.active {
--card-opacity: 1;
}
.pc-card {
height: 80svh;
max-height: 540px;
display: grid;
aspect-ratio: 0.718;
border-radius: var(--card-radius);
position: relative;
background-blend-mode: color-dodge, normal, normal, normal;
animation: glow-bg 12s linear infinite;
box-shadow: rgba(0, 0, 0, 0.8) calc((var(--pointer-from-left) * 10px) - 3px)
calc((var(--pointer-from-top) * 20px) - 6px) 20px -5px;
transition: transform 1s ease;
transform: translateZ(0) rotateX(0deg) rotateY(0deg);
background: rgba(0, 0, 0, 0.9);
backface-visibility: hidden;
overflow: hidden;
}
.pc-card:hover,
.pc-card.active {
transition: none;
transform: translateZ(0) rotateX(var(--rotate-y)) rotateY(var(--rotate-x));
}
.pc-card-shell.entering .pc-card {
transition: transform 180ms ease-out;
}
.pc-card-shell {
position: relative;
z-index: 1;
}
.pc-card * {
display: grid;
grid-area: 1/-1;
border-radius: var(--card-radius);
pointer-events: none;
}
.pc-inside {
inset: 0;
position: absolute;
background-image: var(--inner-gradient);
background-color: rgba(0, 0, 0, 0.9);
transform: none;
}
.pc-shine {
mask-image: var(--icon);
mask-mode: luminance;
mask-repeat: repeat;
mask-size: 150%;
mask-position: top calc(200% - (var(--background-y) * 5)) left
calc(100% - var(--background-x));
transition: filter 0.8s ease;
filter: brightness(0.66) contrast(1.33) saturate(0.33) opacity(0.5);
animation: holo-bg 18s linear infinite;
animation-play-state: running;
mix-blend-mode: color-dodge;
}
.pc-shine,
.pc-shine::after {
--space: 5%;
--angle: -45deg;
transform: translate3d(0, 0, 1px);
overflow: hidden;
z-index: 3;
background: transparent;
background-size: cover;
background-position: center;
background-image: repeating-linear-gradient(
0deg,
var(--sunpillar-clr-1) calc(var(--space) * 1),
var(--sunpillar-clr-2) calc(var(--space) * 2),
var(--sunpillar-clr-3) calc(var(--space) * 3),
var(--sunpillar-clr-4) calc(var(--space) * 4),
var(--sunpillar-clr-5) calc(var(--space) * 5),
var(--sunpillar-clr-6) calc(var(--space) * 6),
var(--sunpillar-clr-1) calc(var(--space) * 7)
),
repeating-linear-gradient(
var(--angle),
#0e152e 0%,
hsl(180, 10%, 60%) 3.8%,
hsl(180, 29%, 66%) 4.5%,
hsl(180, 10%, 60%) 5.2%,
#0e152e 10%,
#0e152e 12%
),
radial-gradient(
farthest-corner circle at var(--pointer-x) var(--pointer-y),
hsla(0, 0%, 0%, 0.1) 12%,
hsla(0, 0%, 0%, 0.15) 20%,
hsla(0, 0%, 0%, 0.25) 120%
);
background-position: 0 var(--background-y),
var(--background-x) var(--background-y), center;
background-blend-mode: color, hard-light;
background-size: 500% 500%, 300% 300%, 200% 200%;
background-repeat: repeat;
}
.pc-shine::before,
.pc-shine::after {
content: '';
background-position: center;
background-size: cover;
grid-area: 1/1;
opacity: 0;
transition: opacity 0.8s ease;
}
.pc-card:hover .pc-shine,
.pc-card.active .pc-shine {
filter: brightness(0.85) contrast(1.5) saturate(0.5);
animation-play-state: paused;
}
.pc-card:hover .pc-shine::before,
.pc-card.active .pc-shine::before,
.pc-card:hover .pc-shine::after,
.pc-card.active .pc-shine::after {
opacity: 1;
}
.pc-shine::before {
background-image: linear-gradient(
45deg,
var(--sunpillar-4),
var(--sunpillar-5),
var(--sunpillar-6),
var(--sunpillar-1),
var(--sunpillar-2),
var(--sunpillar-3)
),
radial-gradient(
circle at var(--pointer-x) var(--pointer-y),
hsl(0, 0%, 70%) 0%,
hsla(0, 0%, 30%, 0.2) 90%
),
var(--grain);
background-size: 250% 250%, 100% 100%, 220px 220px;
background-position: var(--pointer-x) var(--pointer-y), center,
calc(var(--pointer-x) * 0.01) calc(var(--pointer-y) * 0.01);
background-blend-mode: color-dodge;
filter: brightness(calc(2 - var(--pointer-from-center)))
contrast(calc(var(--pointer-from-center) + 2))
saturate(calc(0.5 + var(--pointer-from-center)));
mix-blend-mode: luminosity;
}
.pc-shine::after {
background-position: 0 var(--background-y),
calc(var(--background-x) * 0.4) calc(var(--background-y) * 0.5), center;
background-size: 200% 300%, 700% 700%, 100% 100%;
mix-blend-mode: difference;
filter: brightness(0.8) contrast(1.5);
}
.pc-glare {
transform: translate3d(0, 0, 1.1px);
overflow: hidden;
background-image: radial-gradient(
farthest-corner circle at var(--pointer-x) var(--pointer-y),
hsl(248, 25%, 80%) 12%,
hsla(207, 40%, 30%, 0.8) 90%
);
mix-blend-mode: overlay;
filter: brightness(0.8) contrast(1.2);
z-index: 4;
}
.pc-avatar-content {
mix-blend-mode: luminosity;
overflow: visible;
transform: translateZ(2);
backface-visibility: hidden;
}
.pc-avatar-content .avatar {
width: 100%;
position: absolute;
left: 50%;
transform-origin: 50% 100%;
transform: translateX(calc(-50% + (var(--pointer-from-left) - 0.5) * 6px))
translateZ(0) scaleY(calc(1 + (var(--pointer-from-top) - 0.5) * 0.02))
scaleX(calc(1 + (var(--pointer-from-left) - 0.5) * 0.01));
bottom: -1px;
backface-visibility: hidden;
will-change: transform;
transition: transform 120ms ease-out;
}
.pc-avatar-content::before {
content: '';
position: absolute;
inset: 0;
z-index: 1;
backdrop-filter: none;
pointer-events: none;
}
.pc-user-info {
position: absolute;
--ui-inset: 20px;
--ui-radius-bias: 6px;
bottom: var(--ui-inset);
left: var(--ui-inset);
right: var(--ui-inset);
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(30px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: calc(
max(0px, var(--card-radius) - var(--ui-inset) + var(--ui-radius-bias))
);
padding: 12px 14px;
pointer-events: auto;
}
.pc-user-details {
display: flex;
align-items: center;
gap: 12px;
}
.pc-mini-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
flex-shrink: 0;
}
.pc-mini-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
}
.pc-user-text {
display: flex;
align-items: flex-start;
flex-direction: column;
gap: 6px;
}
.pc-handle {
font-size: 14px;
font-weight: 500;
color: rgba(255, 255, 255, 0.9);
line-height: 1;
}
.pc-status {
font-size: 14px;
color: rgba(255, 255, 255, 0.7);
line-height: 1;
}
.pc-contact-btn {
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 12px 16px;
font-size: 12px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
cursor: pointer;
transition: all 0.2s ease;
backdrop-filter: blur(10px);
}
.pc-contact-btn:hover {
border-color: rgba(255, 255, 255, 0.4);
transform: translateY(-1px);
transition: all 0.2s ease;
}
.pc-content:not(.pc-avatar-content) {
max-height: 100%;
overflow: hidden;
text-align: center;
position: relative;
transform: translate3d(
calc(var(--pointer-from-left) * -6px + 3px),
calc(var(--pointer-from-top) * -6px + 3px),
0.1px
);
z-index: 5;
mix-blend-mode: luminosity;
}
.pc-details {
width: 100%;
position: absolute;
top: 3em;
display: flex;
flex-direction: column;
}
.pc-details h3 {
font-weight: 600;
margin: 0;
font-size: min(5svh, 3em);
margin: 0;
background-image: linear-gradient(to bottom, #fff, #6f6fbe);
background-size: 1em 1.5em;
-webkit-text-fill-color: transparent;
background-clip: text;
-webkit-background-clip: text;
}
.pc-details p {
font-weight: 600;
position: relative;
top: -12px;
white-space: nowrap;
font-size: 16px;
margin: 0 auto;
width: min-content;
background-image: linear-gradient(to bottom, #fff, #4a4ac0);
background-size: 1em 1.5em;
-webkit-text-fill-color: transparent;
background-clip: text;
-webkit-background-clip: text;
}
@keyframes glow-bg {
0% {
--bgrotate: 0deg;
}
100% {
--bgrotate: 360deg;
}
}
@keyframes holo-bg {
0% {
background-position: 0 var(--background-y), 0 0, center;
}
100% {
background-position: 0 var(--background-y), 90% 90%, center;
}
}
@media (max-width: 768px) {
.pc-card {
height: 70svh;
max-height: 450px;
}
.pc-details {
top: 2em;
}
.pc-details h3 {
font-size: min(4svh, 2.5em);
}
.pc-details p {
font-size: 14px;
}
.pc-user-info {
--ui-inset: 15px;
padding: 10px 12px;
}
.pc-mini-avatar {
width: 28px;
height: 28px;
}
.pc-user-details {
gap: 10px;
}
.pc-handle {
font-size: 13px;
}
.pc-status {
font-size: 10px;
}
.pc-contact-btn {
padding: 6px 12px;
font-size: 11px;
}
}
@media (max-width: 480px) {
.pc-card {
height: 60svh;
max-height: 380px;
}
.pc-details {
top: 1.5em;
}
.pc-details h3 {
font-size: min(3.5svh, 2em);
}
.pc-details p {
font-size: 12px;
top: -8px;
}
.pc-user-info {
--ui-inset: 12px;
padding: 8px 10px;
}
.pc-mini-avatar {
width: 24px;
height: 24px;
}
.pc-user-details {
gap: 8px;
}
.pc-handle {
font-size: 12px;
}
.pc-status {
font-size: 9px;
}
.pc-contact-btn {
padding: 5px 10px;
font-size: 10px;
border-radius: 50px;
}
}
@media (max-width: 320px) {
.pc-card {
height: 55svh;
max-height: 320px;
}
.pc-details h3 {
font-size: min(3svh, 1.5em);
}
.pc-details p {
font-size: 11px;
}
.pc-user-info {
padding: 6px 8px;
}
.pc-mini-avatar {
width: 20px;
height: 20px;
}
.pc-user-details {
gap: 6px;
}
.pc-handle {
font-size: 11px;
}
.pc-status {
font-size: 8px;
}
.pc-contact-btn {
padding: 4px 8px;
font-size: 9px;
border-radius: 50px;
}
}
/* Estilos para as métricas */
.metrics-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
margin-top: 24px;
}
.metric-card {
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 16px;
display: flex;
align-items: center;
gap: 12px;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.metric-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.3),
transparent
);
}
.metric-card:hover {
transform: translateY(-2px);
border-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.metric-icon {
width: 48px;
height: 48px;
background: rgba(255, 255, 255, 0.1);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.metric-content {
flex: 1;
min-width: 0;
}
.metric-value {
font-size: 24px;
font-weight: 700;
background: linear-gradient(135deg, #fff 0%, #a5b4fc 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1;
margin-bottom: 4px;
}
.metric-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 500;
}
.metric-card-wide {
grid-column: span 2;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.metric-card-wide::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.3),
transparent
);
}
.metric-card-wide:hover {
transform: translateY(-2px);
border-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.metric-icon-wide {
width: 56px;
height: 56px;
background: rgba(255, 255, 255, 0.1);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.metric-content-wide {
flex: 1;
min-width: 0;
}
.top-badge {
display: inline-block;
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
color: white;
font-size: 11px;
font-weight: 700;
padding: 4px 10px;
border-radius: 20px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.metric-app-name {
font-size: 18px;
font-weight: 700;
color: white;
margin: 0;
line-height: 1;
}
.metric-app-desc {
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
margin-top: 6px;
font-weight: 500;
}
/* Responsividade */
@media (max-width: 480px) {
.metrics-grid {
gap: 10px;
}
.metric-card {
padding: 14px;
}
.metric-card-wide {
padding: 16px;
}
.metric-value {
font-size: 20px;
}
.metric-app-name {
font-size: 16px;
}
}

View File

@ -0,0 +1,386 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import './ProfileCard.css';
import logoImg from '@/assets/images/agape-logo.png';
const DEFAULT_INNER_GRADIENT =
'linear-gradient(145deg,#60496e8c 0%,#71C4FF44 100%)';
const ANIMATION_CONFIG = {
INITIAL_DURATION: 1200,
INITIAL_X_OFFSET: 70,
INITIAL_Y_OFFSET: 60,
DEVICE_BETA_OFFSET: 20,
ENTER_TRANSITION_MS: 180,
};
const clamp = (v, min = 0, max = 100) => Math.min(Math.max(v, min), max);
const round = (v, precision = 3) => parseFloat(v.toFixed(precision));
const adjust = (v, fMin, fMax, tMin, tMax) =>
round(tMin + ((tMax - tMin) * (v - fMin)) / (fMax - fMin));
const ProfileCardComponent = ({
avatarUrl = '<Placeholder for avatar URL>',
iconUrl = '<Placeholder for icon URL>',
grainUrl = '<Placeholder for grain URL>',
innerGradient,
behindGlowEnabled = true,
behindGlowColor,
behindGlowSize,
className = '',
enableTilt = true,
enableMobileTilt = false,
mobileTiltSensitivity = 5,
miniAvatarUrl,
name = 'Javi A. Torres',
title = 'Software Engineer',
handle = 'javicodes',
status = 'Online',
contactText = 'Contact',
showUserInfo = true,
onContactClick,
}) => {
const wrapRef = useRef(null);
const shellRef = useRef(null);
const enterTimerRef = useRef(null);
const leaveRafRef = useRef(null);
const tiltEngine = useMemo(() => {
if (!enableTilt) return null;
let rafId = null;
let running = false;
let lastTs = 0;
let currentX = 0;
let currentY = 0;
let targetX = 0;
let targetY = 0;
const DEFAULT_TAU = 0.14;
const INITIAL_TAU = 0.6;
let initialUntil = 0;
const setVarsFromXY = (x, y) => {
const shell = shellRef.current;
const wrap = wrapRef.current;
if (!shell || !wrap) return;
const width = shell.clientWidth || 1;
const height = shell.clientHeight || 1;
const percentX = clamp((100 / width) * x);
const percentY = clamp((100 / height) * y);
const centerX = percentX - 50;
const centerY = percentY - 50;
const properties = {
'--pointer-x': `${percentX}%`,
'--pointer-y': `${percentY}%`,
'--background-x': `${adjust(percentX, 0, 100, 35, 65)}%`,
'--background-y': `${adjust(percentY, 0, 100, 35, 65)}%`,
'--pointer-from-center': `${clamp(
Math.hypot(percentY - 50, percentX - 50) / 50,
0,
1,
)}`,
'--pointer-from-top': `${percentY / 100}`,
'--pointer-from-left': `${percentX / 100}`,
'--rotate-x': `${round(-(centerX / 5))}deg`,
'--rotate-y': `${round(centerY / 4)}deg`,
};
for (const [k, v] of Object.entries(properties))
wrap.style.setProperty(k, v);
};
const step = (ts) => {
if (!running) return;
if (lastTs === 0) lastTs = ts;
const dt = (ts - lastTs) / 1000;
lastTs = ts;
const tau = ts < initialUntil ? INITIAL_TAU : DEFAULT_TAU;
const k = 1 - Math.exp(-dt / tau);
currentX += (targetX - currentX) * k;
currentY += (targetY - currentY) * k;
setVarsFromXY(currentX, currentY);
const stillFar =
Math.abs(targetX - currentX) > 0.05 ||
Math.abs(targetY - currentY) > 0.05;
if (stillFar || document.hasFocus()) {
rafId = requestAnimationFrame(step);
} else {
running = false;
lastTs = 0;
if (rafId) {
cancelAnimationFrame(rafId);
rafId = null;
}
}
};
const start = () => {
if (running) return;
running = true;
lastTs = 0;
rafId = requestAnimationFrame(step);
};
return {
setImmediate(x, y) {
currentX = x;
currentY = y;
setVarsFromXY(currentX, currentY);
},
setTarget(x, y) {
targetX = x;
targetY = y;
start();
},
toCenter() {
const shell = shellRef.current;
if (!shell) return;
this.setTarget(shell.clientWidth / 2, shell.clientHeight / 2);
},
beginInitial(durationMs) {
initialUntil = performance.now() + durationMs;
start();
},
getCurrent() {
return { x: currentX, y: currentY, tx: targetX, ty: targetY };
},
cancel() {
if (rafId) cancelAnimationFrame(rafId);
rafId = null;
running = false;
lastTs = 0;
},
};
}, [enableTilt]);
const getOffsets = (evt, el) => {
const rect = el.getBoundingClientRect();
return { x: evt.clientX - rect.left, y: evt.clientY - rect.top };
};
const handlePointerMove = useCallback(
(event) => {
const shell = shellRef.current;
if (!shell || !tiltEngine) return;
const { x, y } = getOffsets(event, shell);
tiltEngine.setTarget(x, y);
},
[tiltEngine],
);
const handlePointerEnter = useCallback(
(event) => {
const shell = shellRef.current;
if (!shell || !tiltEngine) return;
shell.classList.add('active');
shell.classList.add('entering');
if (enterTimerRef.current) window.clearTimeout(enterTimerRef.current);
enterTimerRef.current = window.setTimeout(() => {
shell.classList.remove('entering');
}, ANIMATION_CONFIG.ENTER_TRANSITION_MS);
const { x, y } = getOffsets(event, shell);
tiltEngine.setTarget(x, y);
},
[tiltEngine],
);
const handlePointerLeave = useCallback(() => {
const shell = shellRef.current;
if (!shell || !tiltEngine) return;
tiltEngine.toCenter();
const checkSettle = () => {
const { x, y, tx, ty } = tiltEngine.getCurrent();
const settled = Math.hypot(tx - x, ty - y) < 0.6;
if (settled) {
shell.classList.remove('active');
leaveRafRef.current = null;
} else {
leaveRafRef.current = requestAnimationFrame(checkSettle);
}
};
if (leaveRafRef.current) cancelAnimationFrame(leaveRafRef.current);
leaveRafRef.current = requestAnimationFrame(checkSettle);
}, [tiltEngine]);
const handleDeviceOrientation = useCallback(
(event) => {
const shell = shellRef.current;
if (!shell || !tiltEngine) return;
const { beta, gamma } = event;
if (beta == null || gamma == null) return;
const centerX = shell.clientWidth / 2;
const centerY = shell.clientHeight / 2;
const x = clamp(
centerX + gamma * mobileTiltSensitivity,
0,
shell.clientWidth,
);
const y = clamp(
centerY +
(beta - ANIMATION_CONFIG.DEVICE_BETA_OFFSET) * mobileTiltSensitivity,
0,
shell.clientHeight,
);
tiltEngine.setTarget(x, y);
},
[tiltEngine, mobileTiltSensitivity],
);
useEffect(() => {
if (!enableTilt || !tiltEngine) return;
const shell = shellRef.current;
if (!shell) return;
const pointerMoveHandler = handlePointerMove;
const pointerEnterHandler = handlePointerEnter;
const pointerLeaveHandler = handlePointerLeave;
const deviceOrientationHandler = handleDeviceOrientation;
shell.addEventListener('pointerenter', pointerEnterHandler);
shell.addEventListener('pointermove', pointerMoveHandler);
shell.addEventListener('pointerleave', pointerLeaveHandler);
const handleClick = () => {
if (!enableMobileTilt || location.protocol !== 'https:') return;
const anyMotion = window.DeviceMotionEvent;
if (anyMotion && typeof anyMotion.requestPermission === 'function') {
anyMotion
.requestPermission()
.then((state) => {
if (state === 'granted') {
window.addEventListener(
'deviceorientation',
deviceOrientationHandler,
);
}
})
.catch(console.error);
} else {
window.addEventListener('deviceorientation', deviceOrientationHandler);
}
};
shell.addEventListener('click', handleClick);
const initialX =
(shell.clientWidth || 0) - ANIMATION_CONFIG.INITIAL_X_OFFSET;
const initialY = ANIMATION_CONFIG.INITIAL_Y_OFFSET;
tiltEngine.setImmediate(initialX, initialY);
tiltEngine.toCenter();
tiltEngine.beginInitial(ANIMATION_CONFIG.INITIAL_DURATION);
return () => {
shell.removeEventListener('pointerenter', pointerEnterHandler);
shell.removeEventListener('pointermove', pointerMoveHandler);
shell.removeEventListener('pointerleave', pointerLeaveHandler);
shell.removeEventListener('click', handleClick);
window.removeEventListener('deviceorientation', deviceOrientationHandler);
if (enterTimerRef.current) window.clearTimeout(enterTimerRef.current);
if (leaveRafRef.current) cancelAnimationFrame(leaveRafRef.current);
tiltEngine.cancel();
shell.classList.remove('entering');
};
}, [
enableTilt,
enableMobileTilt,
tiltEngine,
handlePointerMove,
handlePointerEnter,
handlePointerLeave,
handleDeviceOrientation,
]);
const cardStyle = useMemo(
() => ({
'--icon': iconUrl ? `url(${iconUrl})` : 'none',
'--grain': grainUrl ? `url(${grainUrl})` : 'none',
'--inner-gradient': innerGradient ?? DEFAULT_INNER_GRADIENT,
'--behind-glow-color': behindGlowColor ?? 'rgba(125, 190, 255, 0.67)',
'--behind-glow-size': behindGlowSize ?? '50%',
}),
[iconUrl, grainUrl, innerGradient, behindGlowColor, behindGlowSize],
);
return (
<div
ref={wrapRef}
className={`pc-card-wrapper ${className}`.trim()}
style={cardStyle}>
{behindGlowEnabled && <div className="pc-behind" />}
<div ref={shellRef} className="pc-card-shell">
<section className="pc-card">
<div className="pc-inside">
<div className="pc-shine" />
<div className="pc-glare" />
<div className="pc-content">
<div className="pc-details">
<div className="w-full flex items-center justify-center mb-8">
<img src={logoImg} className="w-44" alt="Ágape Logo" />
</div>
{/* Seção de Métricas */}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '16px',
padding: '24px',
}}>
{/* Card de Acessos */}
<div className="metric-card bg-gradient-to-br from-purple-900/30 to-blue-900/30">
<div className="metric-content">
<h4 className="metric-value">23.343</h4>
<p className="metric-label">Acessos</p>
</div>
</div>
<div className="metric-card bg-gradient-to-br from-blue-900/30 to-cyan-900/30">
<div className="metric-content">
<h4 className="metric-value">8</h4>
<p className="metric-label">Aplicações</p>
</div>
</div>
<div className="metric-card-wide bg-gradient-to-br from-indigo-900/30 to-purple-900/30">
<div className="metric-content-wide">
<div className="flex items-center gap-2">
<span className="top-badge">TOP 1</span>
<h4 className="metric-app-name">agGestor</h4>
</div>
<p className="metric-app-desc">
Aplicação mais utilizada
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
);
};
const ProfileCard = React.memo(ProfileCardComponent);
export default ProfileCard;

View File

@ -0,0 +1,97 @@
export const summary = {
year: 2025,
orgName: 'Ágape Sistemas e Tecnologia',
numApplicationsUsed: 42,
messageFinal:
'Obrigado por fazer parte desta jornada de transformação digital na gestão pública. Sua dedicação é essencial para construirmos um serviço público mais eficiente e transparente.',
topApplications: [
{
name: 'Portal do Servidor',
uses: 156,
},
{
name: 'Sistema de Protocolo',
uses: 89,
},
{
name: 'Gestão de Processos',
uses: 67,
},
{
name: 'Controle de Ponto',
uses: 54,
},
{
name: 'Licitações Online',
uses: 42,
},
],
distributionByDepartment: [
{
name: 'Saúde',
value: 28,
},
{
name: 'Educação',
value: 22,
},
{
name: 'Administração',
value: 18,
},
{
name: 'Finanças',
value: 16,
},
{
name: 'Infraestrutura',
value: 12,
},
{
name: 'Outros',
value: 4,
},
],
topActions: [
{
action: 'Protocolo de Documentos',
count: 156,
},
{
action: 'Análise de Processos',
count: 134,
},
{
action: 'Solicitação de Aquisições',
count: 98,
},
{
action: 'Controle de Ponto',
count: 87,
},
{
action: 'Gestão de Licitações',
count: 76,
},
{
action: 'Relatórios Gerenciais',
count: 65,
},
],
favoriteHourRange: '09:00 - 11:00',
badge: {
title: 'Agente da Eficiência',
subtitle: 'Nível Ouro',
description:
'Você se destacou pela consistência e otimização no uso das ferramentas Ágape, contribuindo significativamente para a melhoria dos processos da gestão pública.',
},
// Opcional: Estatísticas adicionais
totalDocumentsProcessed: 2456,
averageProcessingTime: '2.3 dias',
efficiencyGain: '37%',
};

View File

@ -0,0 +1,21 @@
interface NavigationProps {
total: number;
current: number;
setCurrent: (index: number) => void;
}
export function Navigation({ total, current, setCurrent }: NavigationProps) {
return (
<div className="fixed top-4 left-1/2 -translate-x-1/2 flex gap-2 z-50">
{Array.from({ length: total }).map((_, i) => (
<button
key={i}
onClick={() => setCurrent(i)}
className={`h-2 rounded-full transition-all ${
current === i ? 'bg-white w-8' : 'bg-white/40 w-4'
}`}
/>
))}
</div>
);
}

View File

@ -0,0 +1,13 @@
interface StorySlideProps {
children: React.ReactNode;
className?: string;
}
export function StorySlide({ children, className }: StorySlideProps) {
return (
<section
className={`h-screen w-full snap-start flex items-center justify-center p-8 ${className}`}>
<div className="w-full max-w-5xl mx-auto">{children}</div>
</section>
);
}

View File

@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/app/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,239 @@
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/app/utils"
import { Button } from "@/views/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

View File

@ -8,6 +8,9 @@
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} },
"allowJs": true,
"checkJs": false,
"jsx": "react-jsx"
} }
} }