Basic display
This commit is contained in:
parent
12c377c92c
commit
b469423d81
10 changed files with 860 additions and 47 deletions
|
|
@ -17,7 +17,7 @@ public function index(): JsonResponse
|
||||||
return response()->json($purchases);
|
return response()->json($purchases);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(Request $request): \Illuminate\Http\RedirectResponse
|
public function store(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'date' => 'required|date|before_or_equal:today',
|
'date' => 'required|date|before_or_equal:today',
|
||||||
|
|
@ -41,7 +41,10 @@ public function store(Request $request): \Illuminate\Http\RedirectResponse
|
||||||
'total_cost' => $validated['total_cost'],
|
'total_cost' => $validated['total_cost'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return Redirect::back()->with('success', 'Purchase added successfully!');
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Purchase added successfully!',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function summary()
|
public function summary()
|
||||||
|
|
@ -64,6 +67,9 @@ public function destroy(Purchase $purchase)
|
||||||
{
|
{
|
||||||
$purchase->delete();
|
$purchase->delete();
|
||||||
|
|
||||||
return Redirect::back()->with('success', 'Purchase deleted successfully!');
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Purchase deleted successfully!',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,9 @@
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans:
|
--font-sans:
|
||||||
'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||||
|
|
||||||
|
--font-mono-display:
|
||||||
|
'Major Mono Display', ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace;
|
||||||
|
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
|
|
||||||
298
resources/js/components/Display/LedCounter.tsx
Normal file
298
resources/js/components/Display/LedCounter.tsx
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { ChevronLeft, ChevronRight, Plus } from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface LedCounterProps {
|
||||||
|
value: number;
|
||||||
|
className?: string;
|
||||||
|
animate?: boolean;
|
||||||
|
currentPrice?: number;
|
||||||
|
onHover?: (isHovered: boolean) => void;
|
||||||
|
// Progress bar props
|
||||||
|
onStatsToggle?: () => void;
|
||||||
|
showStats?: boolean;
|
||||||
|
onAddPurchase?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LedCounter({
|
||||||
|
value,
|
||||||
|
className,
|
||||||
|
animate = true,
|
||||||
|
currentPrice,
|
||||||
|
onHover,
|
||||||
|
onStatsToggle,
|
||||||
|
showStats = false,
|
||||||
|
onAddPurchase
|
||||||
|
}: LedCounterProps) {
|
||||||
|
const [displayValue, setDisplayValue] = useState(0);
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const [hoverTimeout, setHoverTimeout] = useState<NodeJS.Timeout | null>(null);
|
||||||
|
const [currentMilestoneIndex, setCurrentMilestoneIndex] = useState(0);
|
||||||
|
|
||||||
|
// Milestone definitions
|
||||||
|
const milestones = [
|
||||||
|
{ target: 1500, label: '1.5K', color: 'bg-blue-500' },
|
||||||
|
{ target: 3000, label: '3K', color: 'bg-green-500' },
|
||||||
|
{ target: 4500, label: '4.5K', color: 'bg-yellow-500' },
|
||||||
|
{ target: 6000, label: '6K', color: 'bg-red-500' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const currentMilestone = milestones[currentMilestoneIndex];
|
||||||
|
const progress = Math.min((value / currentMilestone.target) * 100, 100);
|
||||||
|
const isCompleted = value >= currentMilestone.target;
|
||||||
|
|
||||||
|
// Milestone navigation
|
||||||
|
const nextMilestone = () => {
|
||||||
|
setCurrentMilestoneIndex((prev) =>
|
||||||
|
prev < milestones.length - 1 ? prev + 1 : 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevMilestone = () => {
|
||||||
|
setCurrentMilestoneIndex((prev) =>
|
||||||
|
prev > 0 ? prev - 1 : milestones.length - 1
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProgressBarClick = () => {
|
||||||
|
if (onStatsToggle) {
|
||||||
|
onStatsToggle();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Animate number changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!animate) {
|
||||||
|
setDisplayValue(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = 1000; // 1 second animation
|
||||||
|
const steps = 60; // 60fps
|
||||||
|
const stepValue = (value - displayValue) / steps;
|
||||||
|
|
||||||
|
if (Math.abs(stepValue) < 0.01) {
|
||||||
|
setDisplayValue(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setDisplayValue(prev => {
|
||||||
|
const next = prev + stepValue;
|
||||||
|
if (Math.abs(next - value) < Math.abs(stepValue)) {
|
||||||
|
clearInterval(timer);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, duration / steps);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [value, displayValue, animate]);
|
||||||
|
|
||||||
|
// Format number appropriately for shares
|
||||||
|
const formatValue = (value: number) => {
|
||||||
|
// If it's a whole number, show it as integer
|
||||||
|
if (value % 1 === 0) {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
// Otherwise show up to 6 decimal places, removing trailing zeros
|
||||||
|
return value.toFixed(6).replace(/\.?0+$/, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
const formattedValue = formatValue(displayValue);
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('de-DE', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
minimumFractionDigits: 4,
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"font-mono text-center select-none relative group cursor-pointer",
|
||||||
|
"bg-black text-red-500",
|
||||||
|
"border-4 border-gray-800 rounded-lg",
|
||||||
|
"shadow-2xl shadow-red-500/20",
|
||||||
|
"p-8 transition-all duration-300",
|
||||||
|
"hover:shadow-red-500/40 hover:border-red-600",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (hoverTimeout) {
|
||||||
|
clearTimeout(hoverTimeout);
|
||||||
|
setHoverTimeout(null);
|
||||||
|
}
|
||||||
|
setIsHovered(true);
|
||||||
|
if (onHover) onHover(true);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
// Delay hiding to allow moving to progress bar
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setIsHovered(false);
|
||||||
|
if (onHover) onHover(false);
|
||||||
|
}, 300); // 300ms delay
|
||||||
|
setHoverTimeout(timeout);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
{/* Background glow effect */}
|
||||||
|
<div className="absolute inset-0 text-red-500/20 blur-sm font-mono-display text-6xl md:text-8xl lg:text-9xl tracking-widest">
|
||||||
|
{formattedValue}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main LED text */}
|
||||||
|
<div className={cn(
|
||||||
|
"relative z-10",
|
||||||
|
"text-6xl md:text-8xl lg:text-9xl",
|
||||||
|
"font-mono-display font-normal tracking-widest",
|
||||||
|
"text-red-500",
|
||||||
|
"drop-shadow-[0_0_10px_rgba(239,68,68,0.8)]",
|
||||||
|
"filter brightness-110"
|
||||||
|
)}>
|
||||||
|
{formattedValue}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subtle scan line effect */}
|
||||||
|
<div className="absolute inset-0 pointer-events-none">
|
||||||
|
<div className="h-full w-full bg-gradient-to-b from-transparent via-red-500/5 to-transparent animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<div className="mt-4 text-red-400/80 text-sm md:text-base font-mono-display tracking-wider">
|
||||||
|
total shares
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hover overlay with price and add button */}
|
||||||
|
<div className={cn(
|
||||||
|
"absolute inset-0 bg-black/90 backdrop-blur-sm",
|
||||||
|
"flex flex-col items-center justify-center",
|
||||||
|
"transition-all duration-300",
|
||||||
|
"rounded-lg z-50",
|
||||||
|
isHovered ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||||
|
)}>
|
||||||
|
{/* Current Price Display */}
|
||||||
|
{currentPrice ? (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-red-400/70 text-sm font-medium tracking-wide mb-2">
|
||||||
|
current price
|
||||||
|
</div>
|
||||||
|
<div className="text-red-500 text-4xl md:text-6xl font-mono-display tracking-wider">
|
||||||
|
{formatCurrency(currentPrice)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-red-400/50 text-sm font-medium">
|
||||||
|
no price data
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar - shows when hovered */}
|
||||||
|
<div className={cn(
|
||||||
|
"absolute bottom-0 left-0 right-0",
|
||||||
|
"bg-black/90 backdrop-blur-sm",
|
||||||
|
"border-t border-red-500/30",
|
||||||
|
"transition-all duration-300 transform",
|
||||||
|
"z-[60]", // Higher than hover overlay z-50
|
||||||
|
isHovered ? "translate-y-0 opacity-100" : "translate-y-full opacity-0",
|
||||||
|
)}>
|
||||||
|
<div className="relative">
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div
|
||||||
|
className="h-2 bg-gray-800 cursor-pointer relative overflow-hidden"
|
||||||
|
onClick={handleProgressBarClick}
|
||||||
|
>
|
||||||
|
{/* Background pulse for completed milestones */}
|
||||||
|
{isCompleted && (
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent animate-pulse" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress fill */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full transition-all duration-1000 ease-out",
|
||||||
|
currentMilestone.color,
|
||||||
|
"shadow-lg",
|
||||||
|
isCompleted ? "animate-pulse" : ""
|
||||||
|
)}
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Glow effect */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute top-0 h-full transition-all duration-1000 ease-out",
|
||||||
|
"bg-gradient-to-r from-transparent to-white/30",
|
||||||
|
"blur-sm"
|
||||||
|
)}
|
||||||
|
style={{ width: `${Math.min(progress + 10, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Milestone Info */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2">
|
||||||
|
{/* Left: Previous milestone button */}
|
||||||
|
<button
|
||||||
|
onClick={prevMilestone}
|
||||||
|
className="text-red-400 hover:text-red-300 transition-colors p-1"
|
||||||
|
aria-label="Previous milestone"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Center: Milestone info */}
|
||||||
|
<div className="flex items-center space-x-4 text-center">
|
||||||
|
<div className="text-red-400 text-sm font-mono">
|
||||||
|
{value.toFixed(2)} / {currentMilestone.target}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-red-500 font-bold text-sm">
|
||||||
|
{currentMilestone.label}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-red-400 text-sm">
|
||||||
|
{isCompleted ? 'COMPLETED' : `${(100 - progress).toFixed(1)}% TO GO`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Add Purchase, Next milestone button and stats toggle */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{/* Add Purchase Button */}
|
||||||
|
{onAddPurchase && (
|
||||||
|
<button
|
||||||
|
onClick={onAddPurchase}
|
||||||
|
className="flex items-center space-x-1 px-2 py-1 rounded bg-red-600/20 border border-red-500/50 text-red-400 hover:bg-red-600/40 hover:text-red-300 transition-colors text-xs"
|
||||||
|
aria-label="Add purchase"
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
<span className="font-mono">ADD</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={nextMilestone}
|
||||||
|
className="text-red-400 hover:text-red-300 transition-colors p-1"
|
||||||
|
aria-label="Next milestone"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Stats indicator */}
|
||||||
|
<div className={cn(
|
||||||
|
"text-xs text-red-400/60 cursor-pointer transition-colors",
|
||||||
|
showStats && "text-red-400"
|
||||||
|
)}>
|
||||||
|
{showStats ? '▲' : '▼'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
167
resources/js/components/Display/MilestoneProgressBar.tsx
Normal file
167
resources/js/components/Display/MilestoneProgressBar.tsx
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { ChevronLeft, ChevronRight, Plus } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface Milestone {
|
||||||
|
target: number;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MilestoneProgressBarProps {
|
||||||
|
currentShares: number;
|
||||||
|
className?: string;
|
||||||
|
onStatsToggle?: () => void;
|
||||||
|
showStats?: boolean;
|
||||||
|
isVisible?: boolean;
|
||||||
|
onAddPurchase?: () => void;
|
||||||
|
onHover?: (isHovered: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const milestones: Milestone[] = [
|
||||||
|
{ target: 1500, label: '1.5K', color: 'bg-blue-500' },
|
||||||
|
{ target: 3000, label: '3K', color: 'bg-green-500' },
|
||||||
|
{ target: 4500, label: '4.5K', color: 'bg-yellow-500' },
|
||||||
|
{ target: 6000, label: '6K', color: 'bg-red-500' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MilestoneProgressBar({
|
||||||
|
currentShares,
|
||||||
|
className,
|
||||||
|
onStatsToggle,
|
||||||
|
showStats = false,
|
||||||
|
isVisible = false,
|
||||||
|
onAddPurchase,
|
||||||
|
onHover
|
||||||
|
}: MilestoneProgressBarProps) {
|
||||||
|
const [currentMilestoneIndex, setCurrentMilestoneIndex] = useState(0);
|
||||||
|
|
||||||
|
const currentMilestone = milestones[currentMilestoneIndex];
|
||||||
|
const progress = Math.min((currentShares / currentMilestone.target) * 100, 100);
|
||||||
|
const isCompleted = currentShares >= currentMilestone.target;
|
||||||
|
|
||||||
|
const nextMilestone = () => {
|
||||||
|
setCurrentMilestoneIndex((prev) =>
|
||||||
|
prev < milestones.length - 1 ? prev + 1 : 0
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevMilestone = () => {
|
||||||
|
setCurrentMilestoneIndex((prev) =>
|
||||||
|
prev > 0 ? prev - 1 : milestones.length - 1
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBarClick = () => {
|
||||||
|
if (onStatsToggle) {
|
||||||
|
onStatsToggle();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute bottom-0 left-0 right-0",
|
||||||
|
"bg-black/90 backdrop-blur-sm",
|
||||||
|
"border-t border-red-500/30",
|
||||||
|
"transition-all duration-300 transform",
|
||||||
|
isVisible ? "translate-y-0 opacity-100" : "translate-y-full opacity-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
onMouseEnter={() => onHover?.(true)}
|
||||||
|
onMouseLeave={() => onHover?.(false)}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div
|
||||||
|
className="h-2 bg-gray-800 cursor-pointer relative overflow-hidden"
|
||||||
|
onClick={handleBarClick}
|
||||||
|
>
|
||||||
|
{/* Background pulse for completed milestones */}
|
||||||
|
{isCompleted && (
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent animate-pulse" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress fill */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full transition-all duration-1000 ease-out",
|
||||||
|
currentMilestone.color,
|
||||||
|
"shadow-lg",
|
||||||
|
isCompleted ? "animate-pulse" : ""
|
||||||
|
)}
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Glow effect */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute top-0 h-full transition-all duration-1000 ease-out",
|
||||||
|
"bg-gradient-to-r from-transparent to-white/30",
|
||||||
|
"blur-sm"
|
||||||
|
)}
|
||||||
|
style={{ width: `${Math.min(progress + 10, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Milestone Info */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2">
|
||||||
|
{/* Left: Previous milestone button */}
|
||||||
|
<button
|
||||||
|
onClick={prevMilestone}
|
||||||
|
className="text-red-400 hover:text-red-300 transition-colors p-1"
|
||||||
|
aria-label="Previous milestone"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Center: Milestone info */}
|
||||||
|
<div className="flex items-center space-x-4 text-center">
|
||||||
|
<div className="text-red-400 text-sm font-mono">
|
||||||
|
{currentShares.toFixed(2)} / {currentMilestone.target}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-red-500 font-bold text-sm">
|
||||||
|
{currentMilestone.label}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-red-400 text-sm">
|
||||||
|
{isCompleted ? 'COMPLETED' : `${(100 - progress).toFixed(1)}% TO GO`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Add Purchase, Next milestone button and stats toggle */}
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{/* Add Purchase Button */}
|
||||||
|
{onAddPurchase && (
|
||||||
|
<button
|
||||||
|
onClick={onAddPurchase}
|
||||||
|
className="flex items-center space-x-1 px-2 py-1 rounded bg-red-600/20 border border-red-500/50 text-red-400 hover:bg-red-600/40 hover:text-red-300 transition-colors text-xs"
|
||||||
|
aria-label="Add purchase"
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
<span className="font-mono">ADD</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={nextMilestone}
|
||||||
|
className="text-red-400 hover:text-red-300 transition-colors p-1"
|
||||||
|
aria-label="Next milestone"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Stats indicator */}
|
||||||
|
<div className={cn(
|
||||||
|
"text-xs text-red-400/60 cursor-pointer transition-colors",
|
||||||
|
showStats && "text-red-400"
|
||||||
|
)}>
|
||||||
|
{showStats ? '▲' : '▼'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
resources/js/components/Display/PurchaseModal.tsx
Normal file
33
resources/js/components/Display/PurchaseModal.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import AddPurchaseForm from '@/components/Transactions/AddPurchaseForm';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
interface PurchaseModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PurchaseModal({ isOpen, onClose, onSuccess }: PurchaseModalProps) {
|
||||||
|
const handleSuccess = () => {
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess();
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="bg-black border-red-500/30 text-red-400 max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-red-500 font-mono tracking-wide">
|
||||||
|
ADD PURCHASE
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<AddPurchaseForm onSuccess={handleSuccess} />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
193
resources/js/components/Display/StatsPanel.tsx
Normal file
193
resources/js/components/Display/StatsPanel.tsx
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface StatsData {
|
||||||
|
totalShares: number;
|
||||||
|
totalInvestment: number;
|
||||||
|
averageCostPerShare: number;
|
||||||
|
currentPrice?: number;
|
||||||
|
currentValue?: number;
|
||||||
|
profitLoss?: number;
|
||||||
|
profitLossPercentage?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatsPanelProps {
|
||||||
|
stats: StatsData;
|
||||||
|
isVisible: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatsPanel({ stats, isVisible, className }: StatsPanelProps) {
|
||||||
|
const [withdrawalRate, setWithdrawalRate] = useState(0.03); // 3% default
|
||||||
|
|
||||||
|
const calculateWithdrawal = (rate: number) => {
|
||||||
|
if (!stats.currentValue) return 0;
|
||||||
|
return stats.currentValue * rate;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (amount: number) => {
|
||||||
|
return new Intl.NumberFormat('de-DE', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
}).format(amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPercentage = (percentage: number) => {
|
||||||
|
return `${percentage >= 0 ? '+' : ''}${percentage.toFixed(2)}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(
|
||||||
|
"fixed bottom-12 left-0 right-0",
|
||||||
|
"bg-black/95 backdrop-blur-sm",
|
||||||
|
"border-t border-red-500/30",
|
||||||
|
"transition-all duration-300 ease-in-out",
|
||||||
|
"transform",
|
||||||
|
isVisible ? "translate-y-0 opacity-100" : "translate-y-full opacity-0",
|
||||||
|
className
|
||||||
|
)}>
|
||||||
|
<div className="container mx-auto px-4 py-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
|
||||||
|
{/* Portfolio Overview */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-red-400 font-bold text-sm uppercase tracking-wide">
|
||||||
|
Portfolio
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2 font-mono text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-red-300/70">Shares:</span>
|
||||||
|
<span className="text-red-400">{stats.totalShares.toFixed(6)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-red-300/70">Invested:</span>
|
||||||
|
<span className="text-red-400">{formatCurrency(stats.totalInvestment)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-red-300/70">Avg Cost:</span>
|
||||||
|
<span className="text-red-400">{formatCurrency(stats.averageCostPerShare)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Value */}
|
||||||
|
{stats.currentPrice && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-red-400 font-bold text-sm uppercase tracking-wide">
|
||||||
|
Current Value
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2 font-mono text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-red-300/70">Price:</span>
|
||||||
|
<span className="text-red-400">{formatCurrency(stats.currentPrice)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-red-300/70">Value:</span>
|
||||||
|
<span className="text-red-400">{formatCurrency(stats.currentValue || 0)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stats.profitLoss !== undefined && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-red-300/70">P&L:</span>
|
||||||
|
<span className={cn(
|
||||||
|
"font-bold",
|
||||||
|
stats.profitLoss >= 0 ? "text-green-400" : "text-red-500"
|
||||||
|
)}>
|
||||||
|
{formatCurrency(stats.profitLoss)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stats.profitLossPercentage !== undefined && (
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-red-300/70">Return:</span>
|
||||||
|
<span className={cn(
|
||||||
|
"font-bold",
|
||||||
|
stats.profitLossPercentage >= 0 ? "text-green-400" : "text-red-500"
|
||||||
|
)}>
|
||||||
|
{formatPercentage(stats.profitLossPercentage)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Withdrawal Estimates */}
|
||||||
|
{stats.currentValue && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-red-400 font-bold text-sm uppercase tracking-wide">
|
||||||
|
Annual Withdrawal
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2 font-mono text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-red-300/70">3%:</span>
|
||||||
|
<span className="text-red-400">{formatCurrency(calculateWithdrawal(0.03))}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-red-300/70">4%:</span>
|
||||||
|
<span className="text-red-400">{formatCurrency(calculateWithdrawal(0.04))}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-red-300/70">Custom:</span>
|
||||||
|
<div className="flex items-center space-x-1">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={withdrawalRate * 100}
|
||||||
|
onChange={(e) => setWithdrawalRate(Number(e.target.value) / 100)}
|
||||||
|
className="w-12 bg-transparent text-red-400 text-right text-xs border-b border-red-500/30 focus:border-red-400 outline-none"
|
||||||
|
min="0"
|
||||||
|
max="10"
|
||||||
|
step="0.1"
|
||||||
|
/>
|
||||||
|
<span className="text-red-300/70 text-xs">%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-red-300/70"></span>
|
||||||
|
<span className="text-red-400">{formatCurrency(calculateWithdrawal(withdrawalRate))}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Monthly Breakdown */}
|
||||||
|
{stats.currentValue && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-red-400 font-bold text-sm uppercase tracking-wide">
|
||||||
|
Monthly Income
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2 font-mono text-sm">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-red-300/70">3%:</span>
|
||||||
|
<span className="text-red-400">{formatCurrency(calculateWithdrawal(0.03) / 12)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-red-300/70">4%:</span>
|
||||||
|
<span className="text-red-400">{formatCurrency(calculateWithdrawal(0.04) / 12)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-red-300/70">Custom:</span>
|
||||||
|
<span className="text-red-400">{formatCurrency(calculateWithdrawal(withdrawalRate) / 12)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -12,9 +12,14 @@ interface PurchaseFormData {
|
||||||
shares: string;
|
shares: string;
|
||||||
price_per_share: string;
|
price_per_share: string;
|
||||||
total_cost: string;
|
total_cost: string;
|
||||||
|
[key: string]: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AddPurchaseForm() {
|
interface AddPurchaseFormProps {
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddPurchaseForm({ onSuccess }: AddPurchaseFormProps) {
|
||||||
const { data, setData, post, processing, errors, reset } = useForm<PurchaseFormData>({
|
const { data, setData, post, processing, errors, reset } = useForm<PurchaseFormData>({
|
||||||
date: new Date().toISOString().split('T')[0], // Today's date in YYYY-MM-DD format
|
date: new Date().toISOString().split('T')[0], // Today's date in YYYY-MM-DD format
|
||||||
shares: '',
|
shares: '',
|
||||||
|
|
@ -42,31 +47,32 @@ export default function AddPurchaseForm() {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
reset();
|
reset();
|
||||||
setData('date', new Date().toISOString().split('T')[0]);
|
setData('date', new Date().toISOString().split('T')[0]);
|
||||||
|
if (onSuccess) {
|
||||||
|
onSuccess();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
<CardHeader>
|
<div className="space-y-4">
|
||||||
<CardTitle>Add VWCE Purchase</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form onSubmit={submit} className="space-y-4">
|
<form onSubmit={submit} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="date">Purchase Date</Label>
|
<Label htmlFor="date" className="text-red-400">Purchase Date</Label>
|
||||||
<Input
|
<Input
|
||||||
id="date"
|
id="date"
|
||||||
type="date"
|
type="date"
|
||||||
value={data.date}
|
value={data.date}
|
||||||
onChange={(e) => setData('date', e.target.value)}
|
onChange={(e) => setData('date', e.target.value)}
|
||||||
max={new Date().toISOString().split('T')[0]}
|
max={new Date().toISOString().split('T')[0]}
|
||||||
|
className="bg-black border-red-500/30 text-red-400 focus:border-red-400"
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.date} />
|
<InputError message={errors.date} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="shares">Number of Shares</Label>
|
<Label htmlFor="shares" className="text-red-400">Number of Shares</Label>
|
||||||
<Input
|
<Input
|
||||||
id="shares"
|
id="shares"
|
||||||
type="number"
|
type="number"
|
||||||
|
|
@ -75,12 +81,13 @@ export default function AddPurchaseForm() {
|
||||||
placeholder="1.234567"
|
placeholder="1.234567"
|
||||||
value={data.shares}
|
value={data.shares}
|
||||||
onChange={(e) => setData('shares', e.target.value)}
|
onChange={(e) => setData('shares', e.target.value)}
|
||||||
|
className="bg-black border-red-500/30 text-red-400 focus:border-red-400 placeholder:text-red-400/30"
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.shares} />
|
<InputError message={errors.shares} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="price_per_share">Price per Share (€)</Label>
|
<Label htmlFor="price_per_share" className="text-red-400">Price per Share (€)</Label>
|
||||||
<Input
|
<Input
|
||||||
id="price_per_share"
|
id="price_per_share"
|
||||||
type="number"
|
type="number"
|
||||||
|
|
@ -89,12 +96,13 @@ export default function AddPurchaseForm() {
|
||||||
placeholder="123.45"
|
placeholder="123.45"
|
||||||
value={data.price_per_share}
|
value={data.price_per_share}
|
||||||
onChange={(e) => setData('price_per_share', e.target.value)}
|
onChange={(e) => setData('price_per_share', e.target.value)}
|
||||||
|
className="bg-black border-red-500/30 text-red-400 focus:border-red-400 placeholder:text-red-400/30"
|
||||||
/>
|
/>
|
||||||
<InputError message={errors.price_per_share} />
|
<InputError message={errors.price_per_share} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="total_cost">Total Cost (€)</Label>
|
<Label htmlFor="total_cost" className="text-red-400">Total Cost (€)</Label>
|
||||||
<Input
|
<Input
|
||||||
id="total_cost"
|
id="total_cost"
|
||||||
type="number"
|
type="number"
|
||||||
|
|
@ -103,9 +111,9 @@ export default function AddPurchaseForm() {
|
||||||
placeholder="1234.56"
|
placeholder="1234.56"
|
||||||
value={data.total_cost}
|
value={data.total_cost}
|
||||||
onChange={(e) => setData('total_cost', e.target.value)}
|
onChange={(e) => setData('total_cost', e.target.value)}
|
||||||
className="bg-neutral-50 dark:bg-neutral-800"
|
className="bg-black border-red-500/30 text-red-400 focus:border-red-400 placeholder:text-red-400/30"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-neutral-500 mt-1">
|
<p className="text-xs text-red-400/60 mt-1">
|
||||||
Auto-calculated from shares × price
|
Auto-calculated from shares × price
|
||||||
</p>
|
</p>
|
||||||
<InputError message={errors.total_cost} />
|
<InputError message={errors.total_cost} />
|
||||||
|
|
@ -114,13 +122,13 @@ export default function AddPurchaseForm() {
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
className="w-full"
|
className="w-full bg-red-600 hover:bg-red-700 text-white border-red-500"
|
||||||
>
|
>
|
||||||
{processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
{processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
Add Purchase
|
Add Purchase
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1,35 +1,140 @@
|
||||||
import { PlaceholderPattern } from '@/components/ui/PlaceholderPattern';
|
import LedCounter from '@/components/Display/LedCounter';
|
||||||
import AppLayout from '@/layouts/app-layout';
|
import PurchaseModal from '@/components/Display/PurchaseModal';
|
||||||
import { type BreadcrumbItem } from '@/types';
|
import StatsPanel from '@/components/Display/StatsPanel';
|
||||||
import { Head } from '@inertiajs/react';
|
import { Head } from '@inertiajs/react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
const breadcrumbs: BreadcrumbItem[] = [
|
interface PurchaseSummary {
|
||||||
{
|
total_shares: number;
|
||||||
title: 'Dashboard',
|
total_investment: number;
|
||||||
href: '/dashboard',
|
average_cost_per_share: number;
|
||||||
},
|
}
|
||||||
];
|
|
||||||
|
interface CurrentPrice {
|
||||||
|
current_price: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
|
const [purchaseData, setPurchaseData] = useState<PurchaseSummary>({
|
||||||
|
total_shares: 0,
|
||||||
|
total_investment: 0,
|
||||||
|
average_cost_per_share: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [priceData, setPriceData] = useState<CurrentPrice>({
|
||||||
|
current_price: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [showStats, setShowStats] = useState(false);
|
||||||
|
const [showPurchaseModal, setShowPurchaseModal] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Fetch purchase summary and current price
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
const [purchaseResponse, priceResponse] = await Promise.all([
|
||||||
|
fetch('/purchases/summary'),
|
||||||
|
fetch('/pricing/current'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (purchaseResponse.ok) {
|
||||||
|
const purchases = await purchaseResponse.json();
|
||||||
|
setPurchaseData(purchases);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (priceResponse.ok) {
|
||||||
|
const price = await priceResponse.json();
|
||||||
|
setPriceData(price);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch data:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Refresh data after successful purchase
|
||||||
|
const handlePurchaseSuccess = async () => {
|
||||||
|
try {
|
||||||
|
const purchaseResponse = await fetch('/purchases/summary');
|
||||||
|
if (purchaseResponse.ok) {
|
||||||
|
const purchases = await purchaseResponse.json();
|
||||||
|
setPurchaseData(purchases);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to refresh purchase data:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Calculate portfolio stats
|
||||||
|
const currentValue = priceData.current_price
|
||||||
|
? purchaseData.total_shares * priceData.current_price
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const profitLoss = currentValue
|
||||||
|
? currentValue - purchaseData.total_investment
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const profitLossPercentage = profitLoss && purchaseData.total_investment > 0
|
||||||
|
? (profitLoss / purchaseData.total_investment) * 100
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const statsData = {
|
||||||
|
totalShares: purchaseData.total_shares,
|
||||||
|
totalInvestment: purchaseData.total_investment,
|
||||||
|
averageCostPerShare: purchaseData.average_cost_per_share,
|
||||||
|
currentPrice: priceData.current_price || undefined,
|
||||||
|
currentValue,
|
||||||
|
profitLoss,
|
||||||
|
profitLossPercentage,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head title="Dashboard" />
|
||||||
|
<div className="min-h-screen bg-black flex items-center justify-center">
|
||||||
|
<div className="text-red-500 font-mono text-lg animate-pulse">
|
||||||
|
LOADING...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout breadcrumbs={breadcrumbs}>
|
<>
|
||||||
<Head title="Dashboard" />
|
<Head title="VWCE Tracker" />
|
||||||
<div className="flex h-full flex-1 flex-col gap-4 rounded-xl p-4 overflow-x-auto">
|
|
||||||
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
|
{/* Main LED Display */}
|
||||||
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
|
<div className="min-h-screen bg-black flex items-center justify-center relative">
|
||||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
<LedCounter
|
||||||
</div>
|
value={purchaseData.total_shares}
|
||||||
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
|
className="max-w-4xl w-full mx-4"
|
||||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
currentPrice={priceData.current_price || undefined}
|
||||||
</div>
|
onStatsToggle={() => setShowStats(!showStats)}
|
||||||
<div className="relative aspect-video overflow-hidden rounded-xl border border-sidebar-border/70 dark:border-sidebar-border">
|
showStats={showStats}
|
||||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
onAddPurchase={() => setShowPurchaseModal(true)}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
<div className="relative min-h-[100vh] flex-1 overflow-hidden rounded-xl border border-sidebar-border/70 md:min-h-min dark:border-sidebar-border">
|
{/* Stats Panel */}
|
||||||
<PlaceholderPattern className="absolute inset-0 size-full stroke-neutral-900/20 dark:stroke-neutral-100/20" />
|
<StatsPanel
|
||||||
</div>
|
stats={statsData}
|
||||||
|
isVisible={showStats}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Purchase Modal */}
|
||||||
|
<PurchaseModal
|
||||||
|
isOpen={showPurchaseModal}
|
||||||
|
onClose={() => setShowPurchaseModal(false)}
|
||||||
|
onSuccess={handlePurchaseSuccess}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</AppLayout>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||||
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
|
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600|major-mono-display:400" rel="stylesheet" />
|
||||||
|
|
||||||
@routes
|
@routes
|
||||||
@viteReactRefresh
|
@viteReactRefresh
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
|
|
||||||
Route::get('/', function () {
|
Route::get('/', function () {
|
||||||
return Inertia::render('welcome');
|
return redirect('/dashboard');
|
||||||
})->name('home');
|
})->name('home');
|
||||||
|
|
||||||
Route::get('dashboard', function () {
|
Route::get('dashboard', function () {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue