diff --git a/frontend/src/components/layout/ShareModal.tsx b/frontend/src/components/layout/ShareModal.tsx index 904170a3..57e6c754 100644 --- a/frontend/src/components/layout/ShareModal.tsx +++ b/frontend/src/components/layout/ShareModal.tsx @@ -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_` 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 = ({ 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(null); + const [error, setError] = useState(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(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 = ({ 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(
e.stopPropagation()}>

{t('editor.share.title')}

- {/* Visibility toggle */} -
-
- - {isPublic ? ( - - - - - - ) : ( - - - - - )} - + {options.map((opt) => { + const isActive = active === opt.value; + const isSaving = savingTo === opt.value; + return ( +
- +
+ {opt.label} + {opt.badge && ( + {opt.badge} + )} + {isActive && ( + + + + )} +
+
{opt.hint}
+ + ); + })}
+ {error &&
{error}
} + {/* Share link */}
= ({ onClose }) => {
- {!isPublic && ( + {active === 'private' && (
{t('editor.share.privateWarning')}
)} @@ -154,91 +211,57 @@ export const ShareModal: React.FC = ({ onClose }) => { const styles: Record = { 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', }, }; diff --git a/frontend/src/i18n/locales/en/common.json b/frontend/src/i18n/locales/en/common.json index 692e8a5a..410675a2 100644 --- a/frontend/src/i18n/locales/en/common.json +++ b/frontend/src/i18n/locales/en/common.json @@ -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." + } + } } } diff --git a/frontend/src/i18n/locales/es/common.json b/frontend/src/i18n/locales/es/common.json index d4788357..3a66c6d3 100644 --- a/frontend/src/i18n/locales/es/common.json +++ b/frontend/src/i18n/locales/es/common.json @@ -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." + } + } } } diff --git a/frontend/src/i18n/locales/pt-br/common.json b/frontend/src/i18n/locales/pt-br/common.json index 83821f17..1c9639a3 100644 --- a/frontend/src/i18n/locales/pt-br/common.json +++ b/frontend/src/i18n/locales/pt-br/common.json @@ -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." + } + } } } diff --git a/frontend/src/i18n/locales/zh-cn/common.json b/frontend/src/i18n/locales/zh-cn/common.json index 6fb3cfef..39e7f3ee 100644 --- a/frontend/src/i18n/locales/zh-cn/common.json +++ b/frontend/src/i18n/locales/zh-cn/common.json @@ -414,5 +414,18 @@ "ctaUpgrade": "升级到 Pro — $15/月", "ctaSeePlans": "查看套餐", "ctaWait": "等待重置" + }, + "editor": { + "share": { + "updateFailed": "无法更改可见性,请重试。", + "visibility": { + "publicLabel": "公开", + "publicHint": "任何人都可以在 velxio.dev 上找到此项目。", + "unlistedLabel": "不列出", + "unlistedHint": "从搜索和画廊中隐藏 —— 只能通过分享链接访问。", + "privateLabel": "私有", + "privateHint": "只有你能看到此项目。" + } + } } } diff --git a/frontend/src/services/projectService.ts b/frontend/src/services/projectService.ts index b75714b4..e9519545 100644 --- a/frontend/src/services/projectService.ts +++ b/frontend/src/services/projectService.ts @@ -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 diff --git a/frontend/src/store/useProjectStore.ts b/frontend/src/store/useProjectStore.ts index 02b3625e..66fc9f7a 100644 --- a/frontend/src/store/useProjectStore.ts +++ b/frontend/src/store/useProjectStore.ts @@ -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((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 }, + }; + }), }));