feat(share-modal): D1.4 — 3-level visibility picker (public/unlisted/private)

Phase 1 D1.4 — replaces the binary public/private toggle in ShareModal
with three radio-button-styled options. Optimistic UI: every option
renders for every user; the backend's 403 (with structured
visibility_not_allowed detail) redirects to /pricing?from=visibility_X
so the pricing page can lead the right pitch.

Why optimistic-then-redirect instead of hiding/locking options:

  1. Discovery — Free / Maker users SEE Pro unlocks Private. That's the
     exact conversion signal the pricing page is trying to surface.
  2. Discovery without surprise — the locked click goes to /pricing
     with a hint, not a dead modal.
  3. Less plan-coupling — this upstream component doesn't need to know
     about the pro overlay's plan store. Backend is the only source of
     truth for what's allowed.

Touched:
  - ShareModal.tsx: full rewrite as a 3-option picker with badges
    (Maker / Pro) on the gated options.
  - projectService.ts: ProjectResponse / ProjectSaveData now declare
    `visibility?: 'public' | 'unlisted' | 'private'`. is_public stays
    declared for backward compat with old callers.
  - useProjectStore.ts: CurrentProject gains `visibility?`; setVisibility
    accepts EITHER the legacy boolean OR the new enum and keeps both
    fields coherent.
  - common.json (4 locales): new editor.share.visibility.{publicLabel,
    publicHint, unlistedLabel, unlistedHint, privateLabel, privateHint}
    + editor.share.updateFailed.

Backend gating + DB migration are in the velxio-prod pro overlay
(commit referencing this submodule pointer).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
David Montero 2026-05-29 05:49:03 +02:00
parent c957767e2e
commit 9a40de78c0
7 changed files with 258 additions and 147 deletions

View File

@ -1,28 +1,67 @@
/**
* ShareModal shows a shareable project link and visibility toggle.
* ShareModal shows a shareable project link and 3-level visibility picker.
*
* Phase 1 D1.4 replaced the binary public/private toggle with a
* three-option enum (public / unlisted / private). The UI is intentionally
* "optimistic": every option renders for every user, regardless of plan.
* The backend gates by `user.plan_id` and responds with HTTP 403 +
* `{ error: "visibility_not_allowed", upgrade_to: "Maker"|"Pro" }` when
* the user picks something their plan doesn't cover. We surface that as
* a redirect to /pricing with a `from=visibility_<level>` hint so the
* pricing page can lead the right pitch.
*
* Why "optimistic-then-redirect" instead of locked buttons:
* 1. Discovery Free / Maker users SEE that Pro unlocks 'private',
* which is exactly the conversion signal we want surfaced.
* 2. Discovery without surprise clicking the locked option goes
* somewhere actionable (/pricing) rather than a no-op or generic
* modal.
* 3. Less plan-coupling this upstream component doesn't need to
* import from the pro overlay's auth store. The backend is the
* single source of truth.
*/
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useProjectStore } from '../../store/useProjectStore';
import { updateProject } from '../../services/projectService';
import type { ProjectVisibility } from '../../services/projectService';
interface ShareModalProps {
onClose: () => void;
}
type VisibilityErrorDetail = {
error?: string;
upgrade_to?: 'Maker' | 'Pro';
upgrade_url?: string;
requested_visibility?: ProjectVisibility;
};
export const ShareModal: React.FC<ShareModalProps> = ({ onClose }) => {
const { t } = useTranslation();
const currentProject = useProjectStore((s) => s.currentProject);
const setVisibility = useProjectStore((s) => s.setVisibility);
const [copied, setCopied] = useState(false);
const [toggling, setToggling] = useState(false);
const [savingTo, setSavingTo] = useState<ProjectVisibility | null>(null);
const [error, setError] = useState<string | null>(null);
// Pick the project's current effective visibility. The backend fills
// this in post-migration; old payloads (or cached pages from before the
// deploy) might be missing it — fall back to is_public so the modal
// never renders empty.
const initialVisibility: ProjectVisibility =
currentProject?.visibility ?? (currentProject?.isPublic ? 'public' : 'private');
const [active, setActive] = useState<ProjectVisibility>(initialVisibility);
useEffect(() => {
setActive(initialVisibility);
}, [initialVisibility]);
if (!currentProject) return null;
const shareUrl = `${window.location.origin}/project/${currentProject.id}`;
const isPublic = currentProject.isPublic;
const handleCopy = () => {
navigator.clipboard.writeText(shareUrl).then(() => {
@ -31,83 +70,107 @@ export const ShareModal: React.FC<ShareModalProps> = ({ onClose }) => {
});
};
const handleToggleVisibility = async () => {
setToggling(true);
const handlePick = async (next: ProjectVisibility) => {
if (next === active || savingTo) return;
setError(null);
setSavingTo(next);
try {
await updateProject(currentProject.id, { is_public: !isPublic });
setVisibility(!isPublic);
} catch {
// Silently fail — user can retry
await updateProject(currentProject.id, {
visibility: next,
// Keep the legacy boolean in sync so any code path still reading
// it (older callers) sees a consistent value.
is_public: next === 'public',
});
setActive(next);
setVisibility(next === 'public');
} catch (err) {
const e = err as { response?: { status?: number; data?: { detail?: VisibilityErrorDetail } } };
const status = e?.response?.status;
const detail = e?.response?.data?.detail;
if (status === 403 && detail?.error === 'visibility_not_allowed') {
// Backend rejected — route the user to /pricing with a hint
// matching the level they tried to set.
const url = detail.upgrade_url || '/pricing';
const from = `visibility_${next}`;
window.location.href = `${url}?from=${from}`;
return;
}
setError(t('editor.share.updateFailed'));
} finally {
setToggling(false);
setSavingTo(null);
}
};
const options: Array<{
value: ProjectVisibility;
label: string;
hint: string;
badge?: string;
}> = [
{
value: 'public',
label: t('editor.share.visibility.publicLabel'),
hint: t('editor.share.visibility.publicHint'),
},
{
value: 'unlisted',
label: t('editor.share.visibility.unlistedLabel'),
hint: t('editor.share.visibility.unlistedHint'),
badge: 'Maker',
},
{
value: 'private',
label: t('editor.share.visibility.privateLabel'),
hint: t('editor.share.visibility.privateHint'),
badge: 'Pro',
},
];
return createPortal(
<div style={styles.overlay} onClick={onClose}>
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
<h2 style={styles.title}>{t('editor.share.title')}</h2>
{/* Visibility toggle */}
<div style={styles.visibilityRow}>
<div style={styles.visibilityInfo}>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{isPublic ? (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="#4ade80"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
) : (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="#f59e0b"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
)}
<span
style={{ color: isPublic ? '#4ade80' : '#f59e0b', fontWeight: 600, fontSize: 13 }}
{/* Three-option visibility picker */}
<div style={styles.visibilityList}>
{options.map((opt) => {
const isActive = active === opt.value;
const isSaving = savingTo === opt.value;
return (
<button
key={opt.value}
onClick={() => handlePick(opt.value)}
disabled={isSaving}
style={{
...styles.visibilityOption,
borderColor: isActive ? '#0e639c' : '#333',
background: isActive ? 'rgba(14,99,156,0.12)' : '#1e1e1e',
opacity: isSaving ? 0.6 : 1,
cursor: isSaving ? 'wait' : 'pointer',
}}
>
{isPublic ? t('editor.share.public') : t('editor.share.private')}
</span>
</span>
<span style={{ color: '#888', fontSize: 12 }}>
{isPublic ? t('editor.share.publicHint') : t('editor.share.privateHint')}
</span>
</div>
<button
onClick={handleToggleVisibility}
disabled={toggling}
style={{
...styles.toggleBtn,
opacity: toggling ? 0.5 : 1,
}}
>
{toggling
? '...'
: isPublic
? t('editor.share.makePrivate')
: t('editor.share.makePublic')}
</button>
<div style={styles.optionHead}>
<span style={styles.optionLabel}>{opt.label}</span>
{opt.badge && (
<span style={styles.badge}>{opt.badge}</span>
)}
{isActive && (
<svg
width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="#4ade80" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
</div>
<div style={styles.optionHint}>{opt.hint}</div>
</button>
);
})}
</div>
{error && <div style={styles.warning}>{error}</div>}
{/* Share link */}
<div style={styles.linkRow}>
<input
@ -120,14 +183,8 @@ export const ShareModal: React.FC<ShareModalProps> = ({ onClose }) => {
<button onClick={handleCopy} style={styles.copyBtn}>
{copied ? (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="#4ade80"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="#4ade80" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
@ -137,7 +194,7 @@ export const ShareModal: React.FC<ShareModalProps> = ({ onClose }) => {
</button>
</div>
{!isPublic && (
{active === 'private' && (
<div style={styles.warning}>{t('editor.share.privateWarning')}</div>
)}
@ -154,91 +211,57 @@ export const ShareModal: React.FC<ShareModalProps> = ({ onClose }) => {
const styles: Record<string, React.CSSProperties> = {
overlay: {
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,.6)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
position: 'fixed', inset: 0, background: 'rgba(0,0,0,.6)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000,
},
modal: {
background: '#252526',
border: '1px solid #3c3c3c',
borderRadius: 8,
padding: '1.75rem',
width: 440,
display: 'flex',
flexDirection: 'column',
gap: 16,
background: '#252526', border: '1px solid #3c3c3c', borderRadius: 8,
padding: '1.75rem', width: 460,
display: 'flex', flexDirection: 'column', gap: 16,
},
title: { color: '#ccc', margin: 0, fontSize: 18, fontWeight: 600 },
visibilityRow: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
visibilityList: { display: 'flex', flexDirection: 'column', gap: 6 },
visibilityOption: {
textAlign: 'left',
padding: '10px 12px',
background: '#1e1e1e',
border: '1px solid #333',
borderRadius: 6,
},
visibilityInfo: {
border: '1px solid #333',
background: '#1e1e1e',
color: '#ccc',
display: 'flex',
flexDirection: 'column',
gap: 4,
gap: 2,
transition: 'all .12s ease',
},
toggleBtn: {
background: 'transparent',
border: '1px solid #555',
borderRadius: 4,
color: '#ccc',
padding: '6px 12px',
fontSize: 12,
cursor: 'pointer',
whiteSpace: 'nowrap',
flexShrink: 0,
optionHead: { display: 'flex', alignItems: 'center', gap: 8 },
optionLabel: { fontWeight: 600, fontSize: 13, color: '#eee' },
optionHint: { fontSize: 11, color: '#888', lineHeight: 1.4 },
badge: {
fontSize: 10,
background: '#0e639c',
color: '#fff',
padding: '1px 6px',
borderRadius: 3,
fontWeight: 600,
},
linkRow: { display: 'flex', gap: 6 },
linkInput: {
flex: 1,
background: '#1e1e1e',
border: '1px solid #444',
borderRadius: 4,
padding: '8px 10px',
color: '#4fc3f7',
fontSize: 13,
fontFamily: 'monospace',
outline: 'none',
flex: 1, background: '#1e1e1e', border: '1px solid #444', borderRadius: 4,
padding: '8px 10px', color: '#4fc3f7', fontSize: 13,
fontFamily: 'monospace', outline: 'none',
},
copyBtn: {
background: '#0e639c',
border: 'none',
borderRadius: 4,
color: '#fff',
padding: '8px 16px',
fontSize: 13,
cursor: 'pointer',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
background: '#0e639c', border: 'none', borderRadius: 4, color: '#fff',
padding: '8px 16px', fontSize: 13, cursor: 'pointer', fontWeight: 500,
display: 'flex', alignItems: 'center',
},
warning: {
background: '#3d2e00',
border: '1px solid #f59e0b44',
borderRadius: 4,
color: '#f59e0b',
padding: '8px 12px',
fontSize: 12,
background: '#3d2e00', border: '1px solid #f59e0b44', borderRadius: 4,
color: '#f59e0b', padding: '8px 12px', fontSize: 12,
},
actions: { display: 'flex', justifyContent: 'flex-end' },
closeBtn: {
background: 'transparent',
border: '1px solid #555',
borderRadius: 4,
color: '#ccc',
padding: '8px 16px',
fontSize: 13,
cursor: 'pointer',
background: 'transparent', border: '1px solid #555', borderRadius: 4,
color: '#ccc', padding: '8px 16px', fontSize: 13, cursor: 'pointer',
},
};

View File

@ -414,5 +414,18 @@
"ctaUpgrade": "Upgrade to Pro — $15/mo",
"ctaSeePlans": "See plans",
"ctaWait": "Wait until reset"
},
"editor": {
"share": {
"updateFailed": "Could not update visibility — please try again.",
"visibility": {
"publicLabel": "Public",
"publicHint": "Anyone can find this project on velxio.dev.",
"unlistedLabel": "Unlisted",
"unlistedHint": "Hidden from search and the gallery — only accessible via the share link.",
"privateLabel": "Private",
"privateHint": "Only you can see this project."
}
}
}
}

View File

@ -414,5 +414,18 @@
"ctaUpgrade": "Pasar a Pro — $15/mes",
"ctaSeePlans": "Ver planes",
"ctaWait": "Esperar al reinicio"
},
"editor": {
"share": {
"updateFailed": "No se pudo cambiar la visibilidad — intentá de nuevo.",
"visibility": {
"publicLabel": "Público",
"publicHint": "Cualquiera puede encontrar este proyecto en velxio.dev.",
"unlistedLabel": "No listado",
"unlistedHint": "Oculto de búsqueda y galería — solo accesible con el link.",
"privateLabel": "Privado",
"privateHint": "Solo vos podés ver este proyecto."
}
}
}
}

View File

@ -414,5 +414,18 @@
"ctaUpgrade": "Fazer upgrade para Pro — $15/mês",
"ctaSeePlans": "Ver planos",
"ctaWait": "Aguardar reinício"
},
"editor": {
"share": {
"updateFailed": "Não foi possível alterar a visibilidade — tente novamente.",
"visibility": {
"publicLabel": "Público",
"publicHint": "Qualquer pessoa pode encontrar este projeto em velxio.dev.",
"unlistedLabel": "Não listado",
"unlistedHint": "Oculto da busca e da galeria — acessível apenas pelo link.",
"privateLabel": "Privado",
"privateHint": "Apenas você pode ver este projeto."
}
}
}
}

View File

@ -414,5 +414,18 @@
"ctaUpgrade": "升级到 Pro — $15/月",
"ctaSeePlans": "查看套餐",
"ctaWait": "等待重置"
},
"editor": {
"share": {
"updateFailed": "无法更改可见性,请重试。",
"visibility": {
"publicLabel": "公开",
"publicHint": "任何人都可以在 velxio.dev 上找到此项目。",
"unlistedLabel": "不列出",
"unlistedHint": "从搜索和画廊中隐藏 —— 只能通过分享链接访问。",
"privateLabel": "私有",
"privateHint": "只有你能看到此项目。"
}
}
}
}

View File

@ -19,12 +19,22 @@ export interface FileGroup {
files: SketchFile[];
}
// Phase 1 D1.3 — three-level visibility enum mirroring the backend
// projects.visibility column. Keep aligned with pro/backend/app/schemas/
// project.py::Visibility.
export type ProjectVisibility = 'public' | 'unlisted' | 'private';
export interface ProjectResponse {
id: string;
name: string;
slug: string;
description: string | null;
is_public: boolean;
// Phase 1 D1.3 — present on rows after the migration; older serializations
// (cached pages or rolled-back deploys) might miss it. Treat undefined as
// 'public' if is_public is true, otherwise 'private', mirroring the
// backend's _to_response fallback.
visibility?: ProjectVisibility;
board_type: string;
files: SketchFile[]; // active board's files (legacy)
file_groups: FileGroup[]; // all boards' file groups
@ -41,6 +51,10 @@ export interface ProjectSaveData {
name: string;
description?: string;
is_public: boolean;
// Phase 1 D1.3 — optional explicit visibility. When omitted the backend
// resolves from `is_public` for backward compat with old clients. New
// clients (ShareModal post-D1.4) always send this.
visibility?: ProjectVisibility;
board_type: string;
files: SketchFile[]; // legacy: active board's files
file_groups?: FileGroup[]; // multi-board: all groups

View File

@ -1,23 +1,45 @@
import { create } from 'zustand';
import type { ProjectVisibility } from '../services/projectService';
interface CurrentProject {
id: string;
slug: string;
ownerUsername: string;
isPublic: boolean;
// Phase 1 D1.3 — three-level visibility. Kept in sync with isPublic
// (which legacy callers still read) by setVisibility().
visibility?: ProjectVisibility;
}
interface ProjectState {
currentProject: CurrentProject | null;
setCurrentProject: (project: CurrentProject) => void;
clearCurrentProject: () => void;
setVisibility: (isPublic: boolean) => void;
// Updated to accept either the legacy boolean OR the new enum so the
// ShareModal callsite and any older callers keep working uniformly.
setVisibility: (next: boolean | ProjectVisibility) => void;
}
export const useProjectStore = create<ProjectState>((set) => ({
currentProject: null,
setCurrentProject: (project) => set({ currentProject: project }),
clearCurrentProject: () => set({ currentProject: null }),
setVisibility: (isPublic) =>
set((s) => (s.currentProject ? { currentProject: { ...s.currentProject, isPublic } } : s)),
setVisibility: (next) =>
set((s) => {
if (!s.currentProject) return s;
// Translate boolean → enum and vice versa so both fields are
// always coherent.
let isPublic: boolean;
let visibility: ProjectVisibility;
if (typeof next === 'boolean') {
isPublic = next;
visibility = next ? 'public' : 'private';
} else {
visibility = next;
isPublic = next === 'public';
}
return {
currentProject: { ...s.currentProject, isPublic, visibility },
};
}),
}));