Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 123 additions & 9 deletions apps/portal/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@ import {
Check,
Plus,
X,
Menu,
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { clearApiKey, api, type Principal } from './api.js';
import { useTenantBase } from './tenant-base.js';
import { theme } from './theme.js';
import { useIsMobile } from './lib/useMediaQuery.js';
import { Dashboard } from './pages/Dashboard.js';
import { LinksPage } from './pages/Links.js';
import { CommissionsPage } from './pages/Commissions.js';
Expand Down Expand Up @@ -149,20 +151,35 @@ function Shell() {
const [auth, setAuth] = useState<AuthState>({ loading: true, principal: null });
const location = useLocation();
const tenantBase = useTenantBase();
const isMobile = useIsMobile();
const [drawerOpen, setDrawerOpen] = useState(false);

useEffect(() => {
api<Principal>('/auth/whoami')
.then((p) => setAuth({ loading: false, principal: p }))
.catch(() => setAuth({ loading: false, principal: null }));
}, []);

// Close the drawer whenever the route changes — a NavItem tap navigates,
// which should dismiss the overlay. Also covers backdrop/programmatic nav.
useEffect(() => {
setDrawerOpen(false);
}, [location.pathname]);

if (auth.loading) return <CenteredMessage>Loading…</CenteredMessage>;
if (!auth.principal) return <Navigate to={`${tenantBase}/login`} state={{ from: location }} replace />;

return (
<div style={{ display: 'flex', minHeight: '100vh', background: theme.bg }}>
<Sidebar principal={auth.principal} />
{isMobile ? (
<MobileDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)}>
<Sidebar principal={auth.principal} variant="drawer" />
</MobileDrawer>
) : (
<Sidebar principal={auth.principal} />
)}
<main style={{ flex: 1, minWidth: 0, overflow: 'auto' }}>
{isMobile && <MobileTopBar onMenu={() => setDrawerOpen(true)} />}
<Routes>
<Route index element={<Dashboard principal={auth.principal} />} />

Expand Down Expand Up @@ -222,7 +239,102 @@ interface ProgramSettings {
logoUrl: string | null;
}

function Sidebar({ principal }: { principal: Principal }) {
/** Mobile-only top bar: hamburger + brand. Sticky so it stays reachable
* while the page scrolls. Reuses the cached 'program-settings' query so
* the brand name/logo match the drawer without a second fetch. */
function MobileTopBar({ onMenu }: { onMenu: () => void }) {
const settings = useQuery({
queryKey: ['program-settings'],
queryFn: () => api<ProgramSettings>('/config/program'),
staleTime: 60_000,
});
const programName = settings.data?.programName || 'OpenPartner';
return (
<header
style={{
position: 'sticky',
top: 0,
zIndex: 30,
display: 'flex',
alignItems: 'center',
gap: 12,
height: 56,
padding: '0 16px',
background: theme.sidebar,
borderBottom: `1px solid ${theme.borderSubtle}`,
}}
>
<button
onClick={onMenu}
aria-label="Open menu"
style={{
background: 'transparent',
border: 'none',
color: theme.text,
cursor: 'pointer',
display: 'inline-flex',
padding: 6,
marginLeft: -6,
}}
>
<Menu size={22} />
</button>
{settings.data?.logoUrl ? (
<img
src={settings.data.logoUrl}
alt={programName}
style={{ width: 24, height: 24, borderRadius: 6, objectFit: 'contain', background: theme.surface2 }}
/>
) : (
<Logo size={24} />
)}
<div style={{ fontSize: 15, fontWeight: 600, letterSpacing: '-0.01em' }}>{programName}</div>
</header>
);
}

/** Slide-in drawer shell for the mobile sidebar. Stays mounted so the
* panel can transition in/out; a tap on the backdrop or any nav link
* inside closes it (route-change in Shell also closes it). */
function MobileDrawer({ open, onClose, children }: { open: boolean; onClose: () => void; children: ReactNode }) {
return (
<>
<div
onClick={onClose}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.5)',
opacity: open ? 1 : 0,
pointerEvents: open ? 'auto' : 'none',
transition: 'opacity 180ms ease',
zIndex: 49,
}}
/>
<div
onClick={(e) => {
if ((e.target as HTMLElement).closest('a')) onClose();
}}
style={{
position: 'fixed',
top: 0,
left: 0,
bottom: 0,
width: 'min(280px, 84vw)',
transform: open ? 'translateX(0)' : 'translateX(-100%)',
transition: 'transform 200ms ease',
boxShadow: open ? '0 0 40px rgba(0,0,0,0.5)' : 'none',
display: 'flex',
zIndex: 50,
}}
>
{children}
</div>
</>
);
}

function Sidebar({ principal, variant = 'sidebar' }: { principal: Principal; variant?: 'sidebar' | 'drawer' }) {
const nav = useNavigate();
const tenantBase = useTenantBase();
const settings = useQuery({
Expand All @@ -233,20 +345,22 @@ function Sidebar({ principal }: { principal: Principal }) {
});
const programName = settings.data?.programName || 'OpenPartner';
const supportEmail = settings.data?.supportEmail || null;
const isDrawer = variant === 'drawer';

return (
<aside
style={{
// Sticky + 100vh pins the aside to the viewport so the middle
// nav's overflow:auto actually scrolls. Without this, the
// Sidebar: sticky + 100vh pins the aside to the viewport so the
// middle nav's overflow:auto actually scrolls. Without this, the
// aside would grow with the main content and the inner overflow
// would never trigger.
width: 248,
height: '100vh',
position: 'sticky',
top: 0,
// Drawer (mobile): the MobileDrawer wrapper owns positioning; the
// aside just fills it as a normal flex column.
width: isDrawer ? '100%' : 248,
height: isDrawer ? '100%' : '100vh',
...(isDrawer ? {} : { position: 'sticky', top: 0 }),
background: theme.sidebar,
borderRight: `1px solid ${theme.borderSubtle}`,
borderRight: isDrawer ? 'none' : `1px solid ${theme.borderSubtle}`,
display: 'flex',
flexDirection: 'column',
padding: '20px 14px',
Expand Down
16 changes: 16 additions & 0 deletions apps/portal/src/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,22 @@ code, pre {
color: #e6e8eb;
}

/* Responsive grid collapse.
*
* The portal styles with inline `style` objects, so multi-column grids set
* `grid-template-columns` inline (e.g. '1fr 1fr', '120px 1fr 140px'). There
* is no stylesheet rule to hang an @media override on — and a normal class
* rule would lose to the inline style. `!important` inside a media query is
* the one author-stylesheet declaration that DOES beat a non-important
* inline style, so we use it to force these grids to a single column on
* phones. Add `className="op-grid-collapse"` to any hardcoded multi-column
* grid that would overflow a ~360px viewport. */
@media (max-width: 768px) {
.op-grid-collapse {
grid-template-columns: 1fr !important;
}
}

/* Remove default focus ring, add our own */
*:focus {
outline: none;
Expand Down
42 changes: 42 additions & 0 deletions apps/portal/src/lib/useMediaQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useEffect, useState } from 'react';

/**
* Subscribe a component to a CSS media query.
*
* The portal styles entirely with inline `style` objects, so there are no
* stylesheets to hang `@media` rules off of. This hook is how components
* branch their inline styles by viewport: it reads `matchMedia` and
* re-renders on change.
*
* SSR-safe: returns `false` when `window` is unavailable (the portal is a
* pure SPA today, but this keeps the hook honest if that ever changes).
*/
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState<boolean>(() => {
if (typeof window === 'undefined' || !window.matchMedia) return false;
return window.matchMedia(query).matches;
});

useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return;
const mql = window.matchMedia(query);
const onChange = () => setMatches(mql.matches);
// Sync immediately in case the query changed since the last render.
onChange();
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, [query]);

return matches;
}

/**
* Mobile breakpoint for the portal: 768px and below collapses the fixed
* sidebar into a drawer and stacks multi-column layouts into one column.
* Matches the common tablet-portrait / phone boundary.
*/
export const MOBILE_BREAKPOINT = 768;

export function useIsMobile(): boolean {
return useMediaQuery(`(max-width: ${MOBILE_BREAKPOINT}px)`);
}
2 changes: 1 addition & 1 deletion apps/portal/src/pages/AdminPartnerCommission.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ function EditCard({
Past commissions aren&rsquo;t recomputed — only future events.
</p>
<ErrorBanner error={save.error ?? clear.error} />
<div style={{ display: 'grid', gridTemplateColumns: '140px 1fr auto', gap: 12, marginBottom: 14, alignItems: 'end' }}>
<div className="op-grid-collapse" style={{ display: 'grid', gridTemplateColumns: '140px 1fr auto', gap: 12, marginBottom: 14, alignItems: 'end' }}>
<div>
<Label>Rate type</Label>
<Select value={type} onChange={(e) => setType(e.target.value as 'percent' | 'fixed')}>
Expand Down
2 changes: 1 addition & 1 deletion apps/portal/src/pages/AdminPartnerCoupons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ export function AdminPartnerCoupons() {
</div>
)}
{availableCampaigns.length > 0 ? (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: 8, alignItems: 'end' }}>
<div className="op-grid-collapse" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: 8, alignItems: 'end' }}>
<div>
<Label>Program</Label>
<select
Expand Down
2 changes: 1 addition & 1 deletion apps/portal/src/pages/AdminPartners.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ function CreatePartner({ onClose, onCreated }: { onClose: () => void; onCreated:
They'll get an email with a one-time link to set up their dashboard.
</div>
<ErrorBanner error={mut.error} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, marginBottom: 14 }}>
<div className="op-grid-collapse" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, marginBottom: 14 }}>
<div>
<Label>Name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Ada Lovelace" />
Expand Down
8 changes: 5 additions & 3 deletions apps/portal/src/pages/AdminPrograms.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { theme } from '../theme.js';
import { Button, Card, EmptyState, ErrorBanner, Input, Label, Page, Select, Table, formatDate } from '../ui.js';
import { renderCommissionSummary } from '../lib/commission-summary.js';
import { PROGRAM_CATEGORIES, categoryLabel } from '@openpartner/db';

Check warning on line 8 in apps/portal/src/pages/AdminPrograms.tsx

View workflow job for this annotation

GitHub Actions / typecheck + test

'categoryLabel' is defined but never used. Allowed unused vars must match /^_/u

interface CommissionSubRule {
trigger: 'every' | 'first' | 'subsequent';
Expand Down Expand Up @@ -262,7 +262,7 @@
Setting <strong>Ends</strong> to a past date marks the campaign Ended immediately. Past the
end date, existing share-links keep redirecting but no new commissions accrue.
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, marginBottom: 18 }}>
<div className="op-grid-collapse" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, marginBottom: 18 }}>
<div>
<Label>Starts</Label>
<Input type="datetime-local" value={startsAt} onChange={(e) => setStartsAt(e.target.value)} />
Expand Down Expand Up @@ -456,7 +456,7 @@
onCategoriesChange={setCategories}
/>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 14, marginBottom: 16 }}>
<div className="op-grid-collapse" style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 14, marginBottom: 16 }}>
<div>
<Label>Attribution window (days)</Label>
<Input type="number" value={windowDays} onChange={(e) => setWindowDays(e.target.value)} />
Expand Down Expand Up @@ -503,7 +503,7 @@
<option value="90">90 days</option>
</Select>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, marginBottom: 16 }}>
<div className="op-grid-collapse" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, marginBottom: 16 }}>
<div>
<Label>Starts (optional)</Label>
<Input type="datetime-local" value={startsAt} onChange={(e) => setStartsAt(e.target.value)} />
Expand Down Expand Up @@ -647,6 +647,7 @@

return (
<div
className="op-grid-collapse"
style={{
display: 'grid',
gridTemplateColumns: '110px 180px 110px 1fr 90px auto',
Expand Down Expand Up @@ -943,6 +944,7 @@
</div>
{enabled && reward && (
<div
className="op-grid-collapse"
style={{
display: 'grid',
gridTemplateColumns: '160px 130px 130px 1fr',
Expand Down
2 changes: 1 addition & 1 deletion apps/portal/src/pages/Connect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export function ConnectPage({ principal }: { principal: Principal }) {
<code style={{ fontSize: 12, color: theme.textMuted }}>{s.accountId}</code>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 10 }}>
<div className="op-grid-collapse" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 10 }}>
<ChecklistItem label="Details submitted" ok={!!s.detailsSubmitted} />
<ChecklistItem label="Charges enabled" ok={!!s.chargesEnabled} />
<ChecklistItem label="Payouts enabled" ok={!!s.payoutsEnabled} />
Expand Down
4 changes: 2 additions & 2 deletions apps/portal/src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ function FunnelChart({ funnel }: { funnel: Funnel }) {
{stages.map((s, i) => {
const width = `${Math.max(6, (s.value / max) * 100)}%`;
return (
<div key={s.key} style={{ display: 'grid', gridTemplateColumns: '120px 1fr 140px', gap: 12, alignItems: 'center' }}>
<div key={s.key} className="op-grid-collapse" style={{ display: 'grid', gridTemplateColumns: '120px 1fr 140px', gap: 12, alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: theme.textMuted }}>
<span style={{ color: theme.accent, display: 'inline-flex' }}>{s.icon}</span>
{s.label}
Expand Down Expand Up @@ -389,7 +389,7 @@ function PartnerCard({
</div>
{partner.stripeConnected && <StatusPill status="connected" />}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div className="op-grid-collapse" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<MiniStat label="Revenue" value={money(partner.revenue)} />
<MiniStat
label="Payouts"
Expand Down
2 changes: 1 addition & 1 deletion apps/portal/src/pages/Install.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ export function InstallPage() {
)}
{mailKind === 'smtp' && (
<>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 12, marginBottom: 12 }}>
<div className="op-grid-collapse" style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 12, marginBottom: 12 }}>
<div>
<Label>SMTP host</Label>
<Input value={smtpHost} onChange={(e) => setSmtpHost(e.target.value)} placeholder="smtp.example.com" />
Expand Down
2 changes: 1 addition & 1 deletion apps/portal/src/pages/admin/Admins.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ function InviteAdmin({ onClose, onCreated }: { onClose: () => void; onCreated: (
They'll get an email with a one-time link to activate their account.
</div>
<ErrorBanner error={mut.error} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, marginBottom: 14 }}>
<div className="op-grid-collapse" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 14, marginBottom: 14 }}>
<div>
<Label>Name</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Taylor Admin" />
Expand Down
2 changes: 1 addition & 1 deletion apps/portal/src/pages/admin/Billing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ function PlanCard({ status }: { status: BillingStatus }) {

function PlanPicker({ onPick, disabled }: { onPick: (plan: Plan) => void; disabled?: boolean }) {
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 14 }}>
<div className="op-grid-collapse" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 14 }}>
{(['flex', 'revshare'] as const).map((p) => (
<button
key={p}
Expand Down
2 changes: 1 addition & 1 deletion apps/portal/src/pages/admin/NetworkCreators.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ function CreatorCard({ creator }: { creator: DirectoryRow }) {
)}
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginBottom: 12 }}>
<div className="op-grid-collapse" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginBottom: 12 }}>
<Stat label="90d revenue" value={revenue > 0 ? `$${revenue.toLocaleString(undefined, { maximumFractionDigits: 0 })}` : '—'} accent={revenue > 0} />
<Stat label="90d clicks" value={creator.clicks90d > 0 ? creator.clicks90d.toLocaleString() : '—'} />
</div>
Expand Down
Loading
Loading