23 - Price tracking opt-in: migration, flag, conditional UI, onboarding checkbox

This commit is contained in:
myrmidex 2026-05-01 22:02:13 +02:00
parent 0861cff8b4
commit 818e8b2276
13 changed files with 119 additions and 74 deletions

View file

@ -24,6 +24,7 @@ public function current(): JsonResponse
return response()->json([ return response()->json([
'asset' => $asset, 'asset' => $asset,
'price_tracking_enabled' => $user?->price_tracking_enabled ?? false,
]); ]);
} }

View file

@ -36,7 +36,11 @@ public function update(Request $request)
return back()->withErrors(['asset' => 'Please set an asset first.']); return back()->withErrors(['asset' => 'Please set an asset first.']);
} }
$assetPrice = AssetPrice::updatePrice($user->asset_id, $validated['date'], $validated['price']); AssetPrice::updatePrice($user->asset_id, $validated['date'], $validated['price']);
if (!$user->price_tracking_enabled) {
$user->update(['price_tracking_enabled' => true]);
}
return back()->with('success', 'Asset price updated successfully!'); return back()->with('success', 'Asset price updated successfully!');
} }

View file

@ -26,6 +26,7 @@ class User extends Authenticatable
'email', 'email',
'password', 'password',
'asset_id', 'asset_id',
'price_tracking_enabled',
]; ];
/** /**
@ -43,6 +44,7 @@ protected function casts(): array
return [ return [
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'price_tracking_enabled' => 'boolean',
]; ];
} }

View file

@ -18,6 +18,7 @@ public function up(): void
$table->timestamp('email_verified_at')->nullable(); $table->timestamp('email_verified_at')->nullable();
$table->string('password'); $table->string('password');
$table->foreignId('asset_id')->nullable()->constrained()->onDelete('set null'); $table->foreignId('asset_id')->nullable()->constrained()->onDelete('set null');
$table->boolean('price_tracking_enabled')->default(false);
$table->rememberToken(); $table->rememberToken();
$table->timestamps(); $table->timestamps();

View file

@ -22,22 +22,6 @@ COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Set working directory # Set working directory
WORKDIR /var/www/html WORKDIR /var/www/html
# Copy composer files and install PHP dependencies
COPY composer.json ./
RUN composer install --no-dev --optimize-autoloader --no-scripts
# Copy package.json and install Node dependencies
COPY package*.json ./
RUN npm ci
# Copy application code
COPY . .
# Set permissions
RUN chown -R www-data:www-data /var/www/html \
&& chmod -R 755 /var/www/html/storage \
&& chmod -R 755 /var/www/html/bootstrap/cache
# Copy and set up container start script # Copy and set up container start script
COPY docker/dev/podman/container-start.sh /usr/local/bin/container-start.sh COPY docker/dev/podman/container-start.sh /usr/local/bin/container-start.sh
RUN chmod +x /usr/local/bin/container-start.sh RUN chmod +x /usr/local/bin/container-start.sh

View file

@ -15,6 +15,10 @@ if ! grep -q "APP_KEY=base64:" /var/www/html/.env; then
sed -i "s/APP_KEY=/APP_KEY=$NEW_KEY/" /var/www/html/.env sed -i "s/APP_KEY=/APP_KEY=$NEW_KEY/" /var/www/html/.env
fi fi
# Install dependencies if needed
[ ! -f vendor/autoload.php ] && composer install --no-interaction
[ ! -d node_modules/.bin ] && npm install
# Run migrations # Run migrations
php artisan migrate --force php artisan migrate --force

View file

@ -21,8 +21,8 @@ services:
- VITE_PORT=5173 - VITE_PORT=5173
volumes: volumes:
- ../../../:/var/www/html:Z - ../../../:/var/www/html:Z
- /var/www/html/node_modules - app_node_modules:/var/www/html/node_modules
- /var/www/html/vendor - app_vendor:/var/www/html/vendor
ports: ports:
- "8000:8000" - "8000:8000"
- "5173:5173" - "5173:5173"
@ -69,4 +69,6 @@ networks:
volumes: volumes:
db_data: db_data:
driver: local driver: local
app_node_modules:
app_vendor:

2
package-lock.json generated
View file

@ -1,5 +1,5 @@
{ {
"name": "site", "name": "html",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {

View file

@ -27,6 +27,7 @@ interface StatsBoxProps {
onAddMilestone?: () => void; onAddMilestone?: () => void;
onUpdatePrice?: () => void; onUpdatePrice?: () => void;
assetSymbol?: string; assetSymbol?: string;
priceTrackingEnabled?: boolean;
} }
export default function StatsBox({ export default function StatsBox({
@ -38,7 +39,8 @@ export default function StatsBox({
onAddPurchase, onAddPurchase,
onAddMilestone, onAddMilestone,
onUpdatePrice, onUpdatePrice,
assetSymbol assetSymbol,
priceTrackingEnabled = false,
}: StatsBoxProps) { }: StatsBoxProps) {
const [isDropdownOpen, setIsDropdownOpen] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false);
@ -78,7 +80,7 @@ export default function StatsBox({
<ComponentTitle>Stats</ComponentTitle> <ComponentTitle>Stats</ComponentTitle>
<div className="flex items-center space-x-2 relative"> <div className="flex items-center space-x-2 relative">
{stats.currentPrice && ( {priceTrackingEnabled && stats.currentPrice && (
<div className="text-red-500 text-sm font-mono tracking-wider"> <div className="text-red-500 text-sm font-mono tracking-wider">
{assetSymbol ?? 'PRICE'}: {formatCurrencyDetailed(stats.currentPrice)} {assetSymbol ?? 'PRICE'}: {formatCurrencyDetailed(stats.currentPrice)}
</div> </div>
@ -119,7 +121,7 @@ export default function StatsBox({
ADD MILESTONE ADD MILESTONE
</button> </button>
)} )}
{onUpdatePrice && ( {priceTrackingEnabled && onUpdatePrice && (
<button <button
onClick={() => { onClick={() => {
onUpdatePrice(); onUpdatePrice();
@ -156,8 +158,8 @@ export default function StatsBox({
<tr> <tr>
<th className="text-left text-red-500 text-xs py-2">DESCRIPTION</th> <th className="text-left text-red-500 text-xs py-2">DESCRIPTION</th>
<th className="text-right text-red-500 text-xs py-2">SHARES</th> <th className="text-right text-red-500 text-xs py-2">SHARES</th>
<th className="text-right text-red-500 text-xs py-2 pr-4">SWR 3%</th> {priceTrackingEnabled && <th className="text-right text-red-500 text-xs py-2 pr-4">SWR 3%</th>}
<th className="text-right text-red-500 text-xs py-2">SWR 4%</th> {priceTrackingEnabled && <th className="text-right text-red-500 text-xs py-2">SWR 4%</th>}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -167,12 +169,16 @@ export default function StatsBox({
<td className="text-right py-1 pr-4"> <td className="text-right py-1 pr-4">
{Math.floor(stats.totalShares).toLocaleString()} {Math.floor(stats.totalShares).toLocaleString()}
</td> </td>
<td className="text-right py-1 pr-4"> {priceTrackingEnabled && (
{stats.currentPrice ? formatCurrency(stats.totalShares * stats.currentPrice * 0.03) : 'N/A'} <td className="text-right py-1 pr-4">
</td> {stats.currentPrice ? formatCurrency(stats.totalShares * stats.currentPrice * 0.03) : 'N/A'}
<td className="text-right py-1"> </td>
{stats.currentPrice ? formatCurrency(stats.totalShares * stats.currentPrice * 0.04) : 'N/A'} )}
</td> {priceTrackingEnabled && (
<td className="text-right py-1">
{stats.currentPrice ? formatCurrency(stats.totalShares * stats.currentPrice * 0.04) : 'N/A'}
</td>
)}
</tr> </tr>
{/* Render milestones after current */} {/* Render milestones after current */}
@ -197,12 +203,16 @@ export default function StatsBox({
<td className="text-right py-1 pr-4"> <td className="text-right py-1 pr-4">
{Math.floor(milestone.target).toLocaleString()} {Math.floor(milestone.target).toLocaleString()}
</td> </td>
<td className="text-right py-1 pr-4"> {priceTrackingEnabled && (
{stats.currentPrice ? formatCurrency(swr3) : 'N/A'} <td className="text-right py-1 pr-4">
</td> {stats.currentPrice ? formatCurrency(swr3) : 'N/A'}
<td className="text-right py-1"> </td>
{stats.currentPrice ? formatCurrency(swr4) : 'N/A'} )}
</td> {priceTrackingEnabled && (
<td className="text-right py-1">
{stats.currentPrice ? formatCurrency(swr4) : 'N/A'}
</td>
)}
</tr> </tr>
); );
})} })}

View file

@ -4,6 +4,44 @@ import AddPurchaseForm from '@/components/Transactions/AddPurchaseForm';
import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm'; import AddMilestoneForm from '@/components/Milestones/AddMilestoneForm';
import UpdatePriceForm from '@/components/Pricing/UpdatePriceForm'; import UpdatePriceForm from '@/components/Pricing/UpdatePriceForm';
function PriceTrackingStep({ onEnable, onSkip }: { onEnable: () => void; onSkip?: () => void }) {
const [enabled, setEnabled] = useState(false);
return (
<div className="space-y-6">
<label className="flex items-center gap-3 cursor-pointer group">
<input
type="checkbox"
checked={enabled}
onChange={e => setEnabled(e.target.checked)}
className="w-4 h-4 accent-red-500"
/>
<span className="text-red-400 font-mono text-sm uppercase tracking-wider group-hover:text-red-300">
Enable price tracking (optional)
</span>
</label>
<p className="text-red-400/60 font-mono text-xs">
Track the current market price of your asset to see portfolio value and P&amp;L. You can enable this later in settings.
</p>
{enabled && (
<div className="border border-red-500/30 p-4">
<UpdatePriceForm onSuccess={onEnable} />
</div>
)}
{!enabled && (
<button
onClick={onSkip}
className="w-full py-2 font-mono text-xs uppercase tracking-wider border border-red-500/50 text-red-400 hover:bg-red-950/30 hover:text-red-300 transition-colors"
>
Skip and finish
</button>
)}
</div>
);
}
interface OnboardingStep { interface OnboardingStep {
id: string; id: string;
title: string; title: string;
@ -43,9 +81,9 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
{ {
id: 'price', id: 'price',
title: 'CURRENT PRICE', title: 'CURRENT PRICE',
description: 'Set current asset price', description: 'Set current asset price (optional)',
completed: false, completed: false,
required: true, required: false,
}, },
]); ]);
@ -76,26 +114,23 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
const priceData = await priceResponse.json(); const priceData = await priceResponse.json();
const hasPrice = !!priceData.current_price; const hasPrice = !!priceData.current_price;
setSteps(prev => prev.map(step => ({ const freshSteps = steps.map(step => ({
...step, ...step,
completed: completed:
(step.id === 'asset' && hasAsset) || (step.id === 'asset' && hasAsset) ||
(step.id === 'purchases' && hasPurchases) || (step.id === 'purchases' && hasPurchases) ||
(step.id === 'milestones' && hasMilestones) || (step.id === 'milestones' && hasMilestones) ||
(step.id === 'price' && hasPrice) (step.id === 'price' && hasPrice)
}))); }));
// Find first incomplete required step setSteps(freshSteps);
const firstIncompleteStep = steps.findIndex(step =>
step.required && !step.completed const firstIncompleteRequired = freshSteps.findIndex(s => s.required && !s.completed);
);
if (firstIncompleteRequired !== -1) {
if (firstIncompleteStep !== -1) { setCurrentStep(firstIncompleteRequired);
setCurrentStep(firstIncompleteStep);
} else { } else {
// All required steps complete, check if we should call onComplete if (onComplete) {
const allRequiredComplete = steps.filter(s => s.required).every(s => s.completed);
if (allRequiredComplete && onComplete) {
onComplete(); onComplete();
} }
} }
@ -110,23 +145,8 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
index === currentStep ? { ...step, completed: true } : step index === currentStep ? { ...step, completed: true } : step
)); ));
// Refresh onboarding status // Refresh onboarding status — handles setCurrentStep and onComplete
await checkOnboardingStatus(); await checkOnboardingStatus();
// Move to next incomplete step or complete onboarding
const nextIncompleteStep = steps.findIndex((step, index) =>
index > currentStep && step.required && !step.completed
);
if (nextIncompleteStep !== -1) {
setCurrentStep(nextIncompleteStep);
} else {
// All required steps complete
const allRequiredComplete = steps.filter(s => s.required).every(s => s.completed);
if (allRequiredComplete && onComplete) {
onComplete();
}
}
}; };
const handleStepSelect = (stepIndex: number) => { const handleStepSelect = (stepIndex: number) => {
@ -157,8 +177,9 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
); );
case 'price': case 'price':
return ( return (
<UpdatePriceForm <PriceTrackingStep
onSuccess={handleStepComplete} onEnable={handleStepComplete}
onSkip={onComplete}
/> />
); );
default: default:
@ -177,7 +198,7 @@ export default function OnboardingFlow({ onComplete }: OnboardingFlowProps) {
[SYSTEM] ONBOARDING SEQUENCE [SYSTEM] ONBOARDING SEQUENCE
</h1> </h1>
<p className="text-red-400/60 font-mono text-sm"> <p className="text-red-400/60 font-mono text-sm">
Initialize your asset tracking system Set up your tracker
</p> </p>
</div> </div>

View file

@ -42,6 +42,7 @@ export default function Dashboard() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [needsOnboarding, setNeedsOnboarding] = useState(false); const [needsOnboarding, setNeedsOnboarding] = useState(false);
const [currentAsset, setCurrentAsset] = useState<any>(null); const [currentAsset, setCurrentAsset] = useState<any>(null);
const [priceTrackingEnabled, setPriceTrackingEnabled] = useState(false);
// Fetch purchase summary, current price, milestones, and check onboarding // Fetch purchase summary, current price, milestones, and check onboarding
useEffect(() => { useEffect(() => {
@ -72,6 +73,7 @@ export default function Dashboard() {
if (assetResponse.ok) { if (assetResponse.ok) {
const assetData = await assetResponse.json(); const assetData = await assetResponse.json();
setCurrentAsset(assetData.asset); setCurrentAsset(assetData.asset);
setPriceTrackingEnabled(assetData.price_tracking_enabled ?? false);
} }
// Check if onboarding is needed after all data is loaded // Check if onboarding is needed after all data is loaded
@ -238,6 +240,7 @@ export default function Dashboard() {
if (assetResponse.ok) { if (assetResponse.ok) {
const assetData = await assetResponse.json(); const assetData = await assetResponse.json();
setCurrentAsset(assetData.asset); setCurrentAsset(assetData.asset);
setPriceTrackingEnabled(assetData.price_tracking_enabled ?? false);
} }
}; };
@ -287,6 +290,7 @@ export default function Dashboard() {
onAddMilestone={() => setActiveForm('milestone')} onAddMilestone={() => setActiveForm('milestone')}
onUpdatePrice={() => setActiveForm('price')} onUpdatePrice={() => setActiveForm('price')}
assetSymbol={currentAsset?.symbol} assetSymbol={currentAsset?.symbol}
priceTrackingEnabled={priceTrackingEnabled}
/> />
</div> </div>

View file

@ -60,9 +60,9 @@ pkgs.mkShell {
} }
dev-rebuild() { dev-rebuild() {
echo "Rebuilding services (down -v + up)..." echo "Rebuilding services (down -v + up --build)..."
podman-compose -f $COMPOSE_FILE down -v podman-compose -f $COMPOSE_FILE down -v
PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE up -d "$@" PODMAN_USERNS=keep-id podman-compose -f $COMPOSE_FILE up -d --build "$@"
echo "" echo ""
podman-compose -f $COMPOSE_FILE ps podman-compose -f $COMPOSE_FILE ps
echo "" echo ""

View file

@ -5,6 +5,18 @@ import { resolve } from 'node:path';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
server: {
host: '0.0.0.0',
port: 5173,
hmr: {
host: 'localhost',
clientPort: 5173,
},
watch: {
usePolling: true,
ignored: ['**/storage/framework/views/**'],
},
},
plugins: [ plugins: [
laravel({ laravel({
input: ['resources/css/app.css', 'resources/js/app.tsx'], input: ['resources/css/app.css', 'resources/js/app.tsx'],