release/0.4.0 #60

Merged
myrmidex merged 30 commits from release/0.4.0 into main 2026-08-16 11:58:52 +02:00
4 changed files with 112 additions and 227 deletions
Showing only changes of commit 8ce8a20d35 - Show all commits

View file

@ -26,8 +26,8 @@ public function show(): JsonResponse
public function store(Request $request): JsonResponse public function store(Request $request): JsonResponse
{ {
$validated = $request->validate([ $validated = $request->validate([
'label' => 'required|string|max:255', 'label' => 'sometimes|string|max:255',
'unit' => 'required|string|max:50', 'unit' => 'sometimes|string|max:50',
'price_tracking_enabled' => 'boolean', 'price_tracking_enabled' => 'boolean',
'symbol' => 'nullable|string|max:10', 'symbol' => 'nullable|string|max:10',
'full_name' => 'nullable|string|max:255', 'full_name' => 'nullable|string|max:255',
@ -46,8 +46,8 @@ public function store(Request $request): JsonResponse
} }
$tracker = $user->tracker()->create([ $tracker = $user->tracker()->create([
'label' => $validated['label'], 'label' => $validated['label'] ?? 'Counter',
'unit' => $validated['unit'], 'unit' => $validated['unit'] ?? 'units',
'price_tracking_enabled' => $validated['price_tracking_enabled'] ?? false, 'price_tracking_enabled' => $validated['price_tracking_enabled'] ?? false,
'asset_id' => $assetId, 'asset_id' => $assetId,
]); ]);

View file

@ -3,7 +3,6 @@ import { useEffect, useState } from 'react';
interface LedDisplayProps { interface LedDisplayProps {
value: number; value: number;
unit?: string;
className?: string; className?: string;
animate?: boolean; animate?: boolean;
onClick?: () => void; onClick?: () => void;
@ -11,7 +10,6 @@ interface LedDisplayProps {
export default function LedDisplay({ export default function LedDisplay({
value, value,
unit,
className, className,
onClick onClick
}: LedDisplayProps) { }: LedDisplayProps) {
@ -23,14 +21,7 @@ export default function LedDisplay({
return; return;
}, [value]); }, [value]);
// Format number with zero-padding for consistent width const formattedValue = Math.floor(displayValue).toString();
const formatValue = (value: number) => {
// Always pad to 5 digits for consistent display width
const integerPart = Math.floor(value);
return integerPart.toString().padStart(5, '0');
};
const formattedValue = formatValue(displayValue);
return ( return (
<div <div
@ -57,11 +48,6 @@ export default function LedDisplay({
{formattedValue} {formattedValue}
</div> </div>
</div> </div>
{unit && (
<div className="text-red-500/50 font-mono text-sm uppercase tracking-widest mt-2">
{unit}
</div>
)}
</div> </div>
); );
} }

View file

@ -1,175 +1,102 @@
import { useState, useEffect, useCallback } from 'react'; import { Button } from '@/components/ui/button';
import AddEntryForm from '@/components/Transactions/AddEntryForm'; import { Input } from '@/components/ui/input';
import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm'; import { Label } from '@/components/ui/label';
import CreateTrackerStep from '@/components/Onboarding/CreateTrackerStep'; import InputError from '@/components/InputError';
import { todayISO } from '@/lib/utils';
interface OnboardingStep { import { LoaderCircle } from 'lucide-react';
id: string; import { FormEventHandler, useState } from 'react';
title: string;
description: string;
completed: boolean;
required: boolean;
}
const STEPS: OnboardingStep[] = [
{ id: 'entries', title: 'STARTING AMOUNT', description: 'Enter your starting amount', completed: false, required: true },
{ id: 'milestones', title: 'SET MILESTONES', description: 'Define your goals', completed: false, required: true },
];
interface OnboardingFlowProps { interface OnboardingFlowProps {
onComplete?: () => void; onComplete?: () => void;
} }
function csrfToken(): string {
return (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content ?? '';
}
export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) { export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
const [trackerCreated, setTrackerCreated] = useState(false); const [startingValue, setStartingValue] = useState('0');
const [currentStep, setCurrentStep] = useState(0); const [processing, setProcessing] = useState(false);
const [steps, setSteps] = useState<OnboardingStep[]>([]); const [error, setError] = useState<string | undefined>();
// On mount: check if a tracker already exists and skip step 1 if so const submit: FormEventHandler = async (e) => {
useEffect(() => { e.preventDefault();
fetch('/tracker') setProcessing(true);
.then(r => r.ok ? r.json() : null) setError(undefined);
.then(data => {
if (data?.tracker) { const headers = {
setTrackerCreated(true); 'Content-Type': 'application/json',
} 'X-CSRF-TOKEN': csrfToken(),
}) Accept: 'application/json',
.catch(() => {}); };
}, []);
const checkOnboardingStatus = useCallback(async (currentSteps: OnboardingStep[]) => {
try { try {
const [entriesData, milestonesData] = await Promise.all([ const trackerResponse = await fetch(route('tracker.store'), {
fetch('/entries/summary').then(r => r.json()), method: 'POST',
fetch('/milestones').then(r => r.json()), headers,
]); body: JSON.stringify({}),
});
const hasEntries = entriesData.total_quantity > 0; if (!trackerResponse.ok && trackerResponse.status !== 409) {
const hasMilestones = milestonesData.length > 0; setError('Could not create the counter. Please try again.');
return;
const freshSteps = currentSteps.map(step => ({
...step,
completed:
(step.id === 'entries' && hasEntries) ||
(step.id === 'milestones' && hasMilestones),
}));
setSteps(freshSteps);
const firstIncompleteRequired = freshSteps.findIndex(s => s.required && !s.completed);
if (firstIncompleteRequired !== -1) {
setCurrentStep(firstIncompleteRequired);
} else if (onComplete) {
onComplete();
} }
} catch (error) {
console.error('Failed to check onboarding status:', error);
}
}, [onComplete]);
useEffect(() => { const quantity = Number(startingValue);
if (!trackerCreated) return;
setSteps(STEPS); if (quantity > 0) {
setCurrentStep(0); const entryResponse = await fetch(route('entries.store'), {
checkOnboardingStatus(STEPS); method: 'POST',
}, [trackerCreated, checkOnboardingStatus]); headers,
body: JSON.stringify({ date: todayISO(), quantity }),
});
const handleTrackerCreated = () => { if (!entryResponse.ok) {
setTrackerCreated(true); setError('Could not save the starting value. Please try again.');
}; return;
}
}
const handleStepComplete = async () => { onComplete?.();
const updatedSteps = steps.map((step, index) => } catch {
index === currentStep ? { ...step, completed: true } : step setError('Something went wrong. Please try again.');
); } finally {
setSteps(updatedSteps); setProcessing(false);
await checkOnboardingStatus(updatedSteps);
};
const handleStepSelect = (stepIndex: number) => {
setCurrentStep(stepIndex);
};
const renderStepContent = () => {
const step = steps[currentStep];
if (!step) return null;
switch (step.id) {
case 'entries':
return <AddEntryForm onSuccess={handleStepComplete} />;
case 'milestones':
return <AddMilestoneForm onSuccess={handleStepComplete} />;
default:
return null;
} }
}; };
return ( return (
<div className="min-h-screen bg-black flex items-center justify-center p-4"> <div className="min-h-screen bg-black flex items-center justify-center p-4">
<div className="w-full max-w-4xl"> <div className="w-full max-w-md">
<div className="border-2 border-red-500 bg-black shadow-[0_0_20px_rgba(239,68,68,0.3)] p-8"> <div className="border-2 border-red-500 bg-black shadow-[0_0_20px_rgba(239,68,68,0.3)] p-8">
<div className="mb-8"> <form onSubmit={submit} className="space-y-4">
<h1 className="text-red-400 font-mono text-2xl font-bold uppercase tracking-wider mb-2"> <Label
[SYSTEM] ONBOARDING SEQUENCE htmlFor="starting-value"
</h1> className="text-red-400 font-mono text-xs uppercase tracking-wider"
<p className="text-red-400/60 font-mono text-sm"> >
{!trackerCreated ? 'Set up your tracker' : 'Configure your tracker'} &gt; Starting Value
</p> </Label>
</div> <Input
id="starting-value"
type="number"
min="0"
step="1"
autoFocus
value={startingValue}
onChange={(e) => setStartingValue(e.target.value)}
className="bg-black border-red-500 text-red-400 focus:border-red-300 font-mono text-sm rounded-none border-2 focus:ring-0 focus:outline-none placeholder:text-red-400/40 transition-all glow-red"
/>
<InputError message={error} />
{!trackerCreated ? ( <Button
<div className="border border-red-500/30 bg-black/50 p-6"> type="submit"
<CreateTrackerStep onSuccess={handleTrackerCreated} /> disabled={processing || startingValue === ''}
</div> className="w-full bg-red-500 hover:bg-red-500 text-black font-mono text-sm font-bold border-red-500 rounded-none border-2 uppercase tracking-wider transition-all glow-red"
) : ( >
<> {processing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
<div className="mb-8"> [INITIALIZE]
<div className="flex items-center justify-between mb-4"> </Button>
{steps.map((step, index) => ( </form>
<button
key={step.id}
onClick={() => handleStepSelect(index)}
className={`flex-1 px-4 py-2 font-mono text-xs uppercase tracking-wider border border-red-500/50 transition-all ${
index === currentStep
? 'bg-red-500 text-black border-red-500'
: step.completed
? 'bg-red-950/50 text-red-300 border-red-400'
: 'bg-black text-red-400/60 hover:text-red-400 hover:border-red-400'
} ${index > 0 ? 'ml-2' : ''}`}
>
{step.completed ? '[✓]' : '[REQ]'} {step.title}
</button>
))}
</div>
<div className="text-center">
<p className="text-red-400 font-mono text-sm">
{steps[currentStep]?.description}
</p>
<p className="text-red-400/60 font-mono text-xs mt-1">
STEP {currentStep + 1}/{steps.length}
</p>
</div>
</div>
<div className="border border-red-500/30 bg-black/50 p-6">
{renderStepContent()}
</div>
<div className="mt-6 pt-4 border-t border-red-500/30">
<div className="flex justify-between items-center">
<p className="text-red-400/60 font-mono text-xs">
[STATUS] {steps.filter(s => s.completed).length}/{steps.length} STEPS COMPLETE
</p>
<p className="text-red-400/60 font-mono text-xs">
{steps.filter(s => !s.completed).length} REQUIRED REMAINING
</p>
</div>
</div>
</>
)}
</div> </div>
</div> </div>
</div> </div>

View file

@ -19,36 +19,37 @@ export default function Dashboard() {
const [needsOnboarding, setNeedsOnboarding] = useState(false); const [needsOnboarding, setNeedsOnboarding] = useState(false);
const [tracker, setTracker] = useState<Tracker | null>(null); const [tracker, setTracker] = useState<Tracker | null>(null);
const loadData = useCallback(async () => {
const [entriesResponse, milestonesResponse, trackerResponse] = await Promise.all([
fetch('/entries/summary'),
fetch('/milestones'),
fetch('/tracker'),
]);
if (entriesResponse.ok) {
const entries = await entriesResponse.json();
setTotalShares(entries.total_quantity);
}
if (milestonesResponse.ok) {
setMilestones(await milestonesResponse.json());
}
let trackerExists = false;
if (trackerResponse.ok) {
const { exists, tracker: trackerData } = await trackerResponse.json();
setTracker(trackerData ?? null);
trackerExists = Boolean(exists);
}
setNeedsOnboarding(!trackerExists);
}, []);
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
try { try {
const [entriesResponse, milestonesResponse, trackerResponse] = await Promise.all([ await loadData();
fetch('/entries/summary'),
fetch('/milestones'),
fetch('/tracker'),
]);
let totalQuantity = 0;
let milestonesCount = 0;
if (entriesResponse.ok) {
const entries = await entriesResponse.json();
setTotalShares(entries.total_quantity);
totalQuantity = entries.total_quantity;
}
if (milestonesResponse.ok) {
const milestonesData = await milestonesResponse.json();
setMilestones(milestonesData);
milestonesCount = milestonesData.length;
}
if (trackerResponse.ok) {
const { tracker: trackerData } = await trackerResponse.json();
setTracker(trackerData ?? null);
}
setNeedsOnboarding(totalQuantity === 0 || milestonesCount === 0);
} catch (error) { } catch (error) {
console.error('Failed to fetch data:', error); console.error('Failed to fetch data:', error);
} finally { } finally {
@ -57,7 +58,7 @@ export default function Dashboard() {
}; };
fetchData(); fetchData();
}, []); }, [loadData]);
const handlePurchaseSuccess = async () => { const handlePurchaseSuccess = async () => {
try { try {
@ -88,35 +89,7 @@ export default function Dashboard() {
setSelectedMilestoneIndex(index); setSelectedMilestoneIndex(index);
}; };
const handleOnboardingComplete = useCallback(async () => { const handleOnboardingComplete = loadData;
const [entriesResponse, milestonesResponse, trackerResponse] = await Promise.all([
fetch('/entries/summary'),
fetch('/milestones'),
fetch('/tracker'),
]);
let totalQuantity = 0;
let milestonesCount = 0;
if (entriesResponse.ok) {
const entries = await entriesResponse.json();
setTotalShares(entries.total_quantity);
totalQuantity = entries.total_quantity;
}
if (milestonesResponse.ok) {
const milestonesData = await milestonesResponse.json();
setMilestones(milestonesData);
milestonesCount = milestonesData.length;
}
if (trackerResponse.ok) {
const { tracker: trackerData } = await trackerResponse.json();
setTracker(trackerData ?? null);
}
setNeedsOnboarding(totalQuantity === 0 || milestonesCount === 0);
}, []);
if (loading) { if (loading) {
return ( return (
@ -158,7 +131,6 @@ export default function Dashboard() {
<div className="pt-32"> <div className="pt-32">
<LedDisplay <LedDisplay
value={totalShares} value={totalShares}
unit={tracker?.unit}
onClick={handleLedClick} onClick={handleLedClick}
/> />
</div> </div>