feat(frontend): /pricing route placeholder + UserResponse subscription fields

Two upstream additions to support private overlays implementing paid tiers
without forking client code:

- store/useAuthStore.ts: UserResponse extended with optional
  is_paid_subscriber, subscription_status, subscription_period_end. The
  backend now returns these in /api/auth/me; the persist middleware
  serialises them automatically.
- pages/PricingPlaceholder.tsx (NEW): the /pricing route. Renders a polite
  "this image is fully free" message for self-hosters plus a
  data-velxio-slot="pricing-page" target where private overlays can
  portal-inject a real pricing page.
- App.tsx: register the /pricing route after /about.

Self-hosted OSS image: /pricing shows the placeholder, no behavioural
change anywhere else. Production with a private overlay: /pricing shows
the overlay's full pricing UI.

Frontend build verified.
This commit is contained in:
David Montero Crespo 2026-05-05 10:24:01 -03:00
parent 5f0e7523ed
commit 77b3e86b50
3 changed files with 78 additions and 0 deletions

View File

@ -28,6 +28,7 @@ import { RaspberryPiSimulatorPage } from './pages/RaspberryPiSimulatorPage';
import { Velxio2Page } from './pages/Velxio2Page';
import { Velxio25Page } from './pages/Velxio25Page';
import { AboutPage } from './pages/AboutPage';
import { PricingPlaceholder } from './pages/PricingPlaceholder';
import { useAuthStore } from './store/useAuthStore';
import './App.css';
@ -68,6 +69,8 @@ function App() {
<Route path="/v2" element={<Velxio2Page />} />
<Route path="/v2-5" element={<Velxio25Page />} />
<Route path="/about" element={<AboutPage />} />
{/* Pricing — placeholder by default; private overlays portal-inject the real page */}
<Route path="/pricing" element={<PricingPlaceholder />} />
{/* Canonical project URL by ID */}
<Route path="/project/:id" element={<ProjectByIdPage />} />
{/* Legacy slug route — redirects to /project/:id */}

View File

@ -0,0 +1,69 @@
/**
* PricingPlaceholder the route at /pricing.
*
* In the open-source build this renders a minimal heading + slot div. It
* exists so that:
* 1. Private overlays can portal-inject a real PricingPage into the slot
* (data-velxio-slot="pricing-page") without forking this file.
* 2. Self-hosters who navigate to /pricing get a polite "this image
* doesn't sell anything" page rather than a 404.
*
* The route is registered in App.tsx. The slot pattern is the same as
* data-velxio-slot="user-menu" / "admin-tabs" / "admin-tab-content".
*/
import { useEffect } from 'react';
import { AppHeader } from '../components/layout/AppHeader';
export const PricingPlaceholder = () => {
useEffect(() => {
document.title = 'Pricing — Velxio';
}, []);
return (
<div style={{ minHeight: '100vh', background: '#1e1e1e', color: '#e8e8e8' }}>
<AppHeader />
<div data-velxio-slot="pricing-page">
{/* Default content only visible if no overlay is mounted (i.e.
self-hosted OSS image without a private pricing overlay). */}
<main
style={{
maxWidth: 720,
margin: '60px auto',
padding: '0 24px',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
lineHeight: 1.6,
}}
>
<h1 style={{ marginTop: 0 }}>Pricing</h1>
<p>
This Velxio instance is self-hosted from the open-source image
all features are free for everyone using it.
</p>
<p>
The hosted version at{' '}
<a
href="https://velxio.dev"
style={{ color: '#4fc3f7', textDecoration: 'none' }}
>
velxio.dev
</a>{' '}
offers an optional Pro tier that helps fund development.
</p>
<p style={{ color: '#888', fontSize: 13, marginTop: 32 }}>
Source code is{' '}
<a
href="https://github.com/davidmonterocrespo24/velxio"
style={{ color: '#888' }}
>
MIT-licensed on GitHub
</a>
.
</p>
</main>
</div>
</div>
);
};
export default PricingPlaceholder;

View File

@ -9,6 +9,12 @@ export interface UserResponse {
avatar_url: string | null;
is_admin: boolean;
created_at: string;
// Subscription state — populated by deployments that wire an external
// billing system (e.g. velxio.dev). Self-hosted OSS images leave these
// at the safe defaults (no paid features unlock).
is_paid_subscriber?: boolean;
subscription_status?: string | null;
subscription_period_end?: string | null;
}
interface AuthState {