diff --git a/.env.example b/.env.example index a9489af..1260df8 100644 --- a/.env.example +++ b/.env.example @@ -40,6 +40,10 @@ CORS_ORIGIN=http://localhost # de passe. Une instance sans aucun compte accepte quand même le premier (le vôtre) : # créez-le juste après le premier démarrage, avant de donner l'adresse. # ALLOW_REGISTRATION=true +# À true, l'app invite à créer son propre arbre : « Faire le mien » dans le menu Ajouter de la +# famille, l'inscription proposée dans les messages de la démo, un message d'invitation pour Noël +# (du 1er décembre au 6 janvier). Éteint par défaut, allumé sur app.parenthese.io. +# SIGNUP_PROMPTS=false # ── Chercher quelqu'un dans des sources publiques ──────────────────────────── # Sources que le serveur interroge pour le panneau « Quelqu'un ailleurs » (menu +) : diff --git a/README.fr.md b/README.fr.md index 3b1be31..dc5d3f1 100644 --- a/README.fr.md +++ b/README.fr.md @@ -59,6 +59,8 @@ Pour ouvrir l'instance à la famille depuis Internet, placez un reverse proxy HT Pour qu'une personne qui tombe sur l'adresse ne puisse pas s'inscrire et y créer ses arbres, mettez `ALLOW_REGISTRATION=false` dans `.env`, puis lancez `docker compose up -d`. Les comptes existants se connectent et créent leurs arbres comme avant, la famille ouvre toujours les arbres partagés avec le lien et le mot de passe, et l'application ne propose plus de créer de compte. Une instance sans aucun compte accepte quand même le premier, la variable peut donc être posée avant le tout premier démarrage : créez votre compte tout de suite, avant de donner l'adresse. +Votre instance ne pousse personne à y créer un compte. Sur app.parenthese.io, l'instance hébergée, l'application invite à créer son propre arbre : la famille qui ouvre un arbre partagé trouve « Faire le mien » dans le menu Ajouter, les messages de la démo proposent l'inscription, et du 1er décembre au 6 janvier le message d'invitation parle de Noël. Ces invitations sont éteintes par défaut. Pour les allumer, mettez `SIGNUP_PROMPTS=true` dans `.env` (elles restent coupées si l'inscription est fermée). + #### Chercher quelqu'un dans des sources publiques Le menu « + » propose « Quelqu'un ailleurs » : un panneau à droite de l'arbre où l'on tape un nom pour voir ce que les sources publiques disent de cette personne. Les liens vers des sites sans recherche interrogeable d'ici (presse ancienne sur Gallica, registres matricules sur FranceArchives, Geneanet, FamilySearch et quelques autres, la France d'abord) marchent toujours : votre navigateur ouvre le site avec le nom déjà tapé, et rien ne passe par le serveur. diff --git a/README.md b/README.md index 3b6b390..f59bd54 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ To open the instance to your family over the Internet, put an HTTPS reverse prox So that people who come across the address can't sign up and start their own trees, set `ALLOW_REGISTRATION=false` in `.env`, then run `docker compose up -d`. Existing accounts keep signing in and creating trees, relatives still open shared trees with the link and password, and the app no longer offers to create an account. An instance with no account at all still accepts the first one, so the variable can be set before the very first start: create your own account right away, before you give out the address. +Your instance doesn't push anyone to create an account. On app.parenthese.io, the hosted instance, the app suggests starting your own tree: relatives who open a shared tree find « Faire le mien » ("Make my own") in the Add menu, the demo's messages offer to sign up, and from December 1 to January 6 the invitation message mentions Christmas. These prompts are off by default. To turn them on, set `SIGNUP_PROMPTS=true` in `.env` (they stay off while registration is closed). + #### Search for someone in public records The "+" menu offers "Someone elsewhere": a panel on the right of the tree where you type a name and see what public records hold about that person. Links to sites without a search we can query from here (old newspapers on Gallica, military registers on FranceArchives, Geneanet, FamilySearch and a few others, France first) always work: your browser opens the site with the name already typed in, and nothing goes through the server. diff --git a/backend/.env.example b/backend/.env.example index 254e824..bfdea6a 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -14,6 +14,9 @@ IMMICH_ALLOWED_API_URLS="" # Création de compte. À false, seuls les comptes existants se connectent ; une instance sans aucun # compte accepte quand même le premier, celui du propriétaire. ALLOW_REGISTRATION=true +# Invitations à créer son propre arbre (famille, démo, message de Noël). Éteintes par défaut, allumées sur +# l'instance hébergée. +SIGNUP_PROMPTS=false # Connexion par un fournisseur OIDC, éteinte sans OIDC_ISSUER (voir le README). PASSWORD_LOGIN=false retire # les mots de passe Parenthèse. OIDC_ISSUER="" diff --git a/backend/openapi/v1.yaml b/backend/openapi/v1.yaml index 54c67fa..95027b7 100644 --- a/backend/openapi/v1.yaml +++ b/backend/openapi/v1.yaml @@ -563,7 +563,10 @@ paths: properties: name: type: string - required: [registration, passwordLogin, oidc] + signupPrompts: + type: boolean + description: true when SIGNUP_PROMPTS=true and accounts can be created. The app then suggests starting one's own tree to relatives and demo visitors + required: [registration, passwordLogin, oidc, signupPrompts] /auth/oidc/start: get: diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 1f7625f..5206518 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -68,6 +68,11 @@ const envSchema = z.object({ // Internet ne laisse pas un inconnu qui trouve l'adresse créer ses arbres. Une instance sans aucun compte // accepte quand même le premier, celui du propriétaire (voir lib/registration.ts). ALLOW_REGISTRATION: z.string().default('true').transform(parseBooleanFlag('ALLOW_REGISTRATION')), + // Invitations à créer son propre arbre, ajoutées pour l'instance hébergée app.parenthese.io : « Faire le mien » + // dans le menu Ajouter de la famille, l'inscription proposée dans les messages de la démo, message d'invitation + // de Noël. Éteintes par défaut : une instance auto-hébergée n'a pas à pousser vers la création de compte. + // L'instance hébergée les allume dans ecosystem.config.cjs. + SIGNUP_PROMPTS: z.string().default('false').transform(parseBooleanFlag('SIGNUP_PROMPTS')), // Connexion par un fournisseur d'identité OIDC (#115 ; Authentik, Authelia, Keycloak, Pocket ID…). Éteinte sans // OIDC_ISSUER. Le fournisseur renvoie vers OIDC_REDIRECT_URI, l'adresse publique de /auth/oidc/callback. OIDC_ISSUER: z.string().trim().default(''), diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 559f428..59ea794 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -109,11 +109,16 @@ export const authRoutes: FastifyPluginAsync = async (app) => { app.get( '/options', { config: { rateLimit: { max: 60, timeWindow: '1 minute' } } }, - async (_request, reply) => reply.send({ - registration: await isRegistrationOpen(app.prisma), - passwordLogin: env.PASSWORD_LOGIN, - oidc: isOidcEnabled() ? { name: env.OIDC_PROVIDER_NAME } : null, - }), + async (_request, reply) => { + const registration = await isRegistrationOpen(app.prisma) + return reply.send({ + registration, + passwordLogin: env.PASSWORD_LOGIN, + oidc: isOidcEnabled() ? { name: env.OIDC_PROVIDER_NAME } : null, + // Rien à proposer quand l'instance ne crée plus de compte + signupPrompts: env.SIGNUP_PROMPTS && registration && env.PASSWORD_LOGIN, + }) + }, ) app.post( diff --git a/backend/test/oidc.test.ts b/backend/test/oidc.test.ts index 2d1648b..57c09d7 100644 --- a/backend/test/oidc.test.ts +++ b/backend/test/oidc.test.ts @@ -114,7 +114,7 @@ function fragmentOf(location: string | undefined) { describe('départ et retour', () => { it("annonce le fournisseur à l'écran de connexion", async () => { const res = await app.inject({ method: 'GET', url: '/auth/options' }) - expect(res.json()).toEqual({ registration: true, passwordLogin: true, oidc: { name: 'Authentik' } }) + expect(res.json()).toEqual({ registration: true, passwordLogin: true, oidc: { name: 'Authentik' }, signupPrompts: false }) }) it('part chez le fournisseur avec un cookie signé, httpOnly, lax, sécurisé en https', async () => { diff --git a/backend/test/registration-closed.test.ts b/backend/test/registration-closed.test.ts index 1c3a7ec..3f877a4 100644 --- a/backend/test/registration-closed.test.ts +++ b/backend/test/registration-closed.test.ts @@ -27,7 +27,7 @@ vi.mock('../src/lib/prisma.js', () => ({ })) let app: FastifyInstance -let env: { ALLOW_REGISTRATION: boolean } +let env: { ALLOW_REGISTRATION: boolean, SIGNUP_PROMPTS: boolean } const newAccount = { firstName: 'Camille', email: 'camille@example.com', password: 'securepass123' } @@ -50,6 +50,7 @@ afterAll(async () => { beforeEach(() => { vi.clearAllMocks() env.ALLOW_REGISTRATION = false + env.SIGNUP_PROMPTS = false prismaMock.user.findUnique.mockResolvedValue(null) prismaMock.user.count.mockResolvedValue(3) prismaMock.user.create.mockResolvedValue({ id: 'user-1', email: newAccount.email, firstName: 'Camille' }) @@ -102,7 +103,7 @@ describe('inscription fermée', () => { const res = await app.inject({ method: 'GET', url: '/auth/options' }) expect(res.statusCode).toBe(200) - expect(res.json()).toEqual({ registration: false, passwordLogin: true, oidc: null }) + expect(res.json()).toEqual({ registration: false, passwordLogin: true, oidc: null, signupPrompts: false }) }) it('instance neuve : le premier compte se crée, sous verrou', async () => { @@ -145,3 +146,22 @@ describe('inscription ouverte (par défaut)', () => { expect(prismaMock.user.count).not.toHaveBeenCalled() }) }) + +// Invitations à créer son arbre (SIGNUP_PROMPTS) : éteintes par défaut, jamais annoncées quand l'instance ne crée plus de compte +describe('invitations à créer son arbre', () => { + it('éteintes par défaut', async () => { + env.ALLOW_REGISTRATION = true + expect((await app.inject({ method: 'GET', url: '/auth/options' })).json().signupPrompts).toBe(false) + }) + + it('allumées avec SIGNUP_PROMPTS et une inscription ouverte', async () => { + env.ALLOW_REGISTRATION = true + env.SIGNUP_PROMPTS = true + expect((await app.inject({ method: 'GET', url: '/auth/options' })).json().signupPrompts).toBe(true) + }) + + it("coupées quand l'inscription est fermée, même allumées", async () => { + env.SIGNUP_PROMPTS = true + expect((await app.inject({ method: 'GET', url: '/auth/options' })).json().signupPrompts).toBe(false) + }) +}) diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index 7345673..27f0646 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -12,6 +12,9 @@ module.exports = { PORT: 4000, // nginx relaie vers 127.0.0.1:4000 (deploy/nginx.conf) : l'API n'écoute pas sur les interfaces publiques HOST: '127.0.0.1', + // Instance hébergée app.parenthese.io : invite la famille et la démo à créer leur arbre (SIGNUP_PROMPTS, + // éteint par défaut en auto-hébergement) + SIGNUP_PROMPTS: 'true', }, max_memory_restart: '512M', log_date_format: 'YYYY-MM-DD HH:mm:ss Z', diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ea1410a..6d8528b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -77,6 +77,7 @@ import { getCoupleParentLinks, getSiblingParentLinks } from './utils/familyLinks import { getUploadMimeType, inferMediaTypeFromMimeType, validateMediaFile } from './utils/mediaUpload' import { getSource, + rememberSource, identifyUser, resetUser, toInteractionMediaType, @@ -607,8 +608,22 @@ function App() { const [accountEntryMode, setAccountEntryMode] = useState('login') // Options de l'instance. Inscription fermée (ALLOW_REGISTRATION=false, #113) ou mot de passe désactivé // (PASSWORD_LOGIN=false, #115) : plus de « Créer un compte » nulle part. oidc : le bouton du fournisseur. - const [authOptions, setAuthOptions] = useState({ registration: true, passwordLogin: true, oidc: null }) + const [authOptions, setAuthOptions] = useState({ registration: true, passwordLogin: true, oidc: null, signupPrompts: false }) const registrationOpen = authOptions.registration !== false && authOptions.passwordLogin !== false + // Invitations à créer son arbre (SIGNUP_PROMPTS, allumé sur l'instance hébergée) : « Faire le mien » pour la + // famille, l'inscription au bout des messages de la démo, le message d'invitation de Noël + const signupPrompts = authOptions.signupPrompts === true && registrationOpen + // Message de la démo suivi de l'invitation ; le lien ouvre l'inscription sans recharger la page + const demoSignupMessage = (lead) => ( + + {lead} {t('Pour votre famille,')}{' '} + { event.preventDefault(); handleUseAccount('register', 'demo') }} + >{t('créez votre arbre')}. + + ) const [treeWizardVisible, setTreeWizardVisible] = useState(false) const [treeWizardLoading, setTreeWizardLoading] = useState(false) const [treeWelcomeVisible, setTreeWelcomeVisible] = useState(false) @@ -1107,7 +1122,8 @@ function App() { if (!left) return editMode.saveDraft() editMode.deactivate() - }, [leaveSelectedPerson, editMode.saveDraft, editMode.deactivate]) // eslint-disable-line react-hooks/exhaustive-deps + if (isDemoMode && signupPrompts) setDraftNotice(demoSignupMessage(t('Votre essai reste dans la démo.'))) + }, [leaveSelectedPerson, editMode.saveDraft, editMode.deactivate, isDemoMode, signupPrompts]) // eslint-disable-line react-hooks/exhaustive-deps const handleConfirmDiscard = useCallback(() => { editMode.clearDraft() @@ -1129,9 +1145,10 @@ function App() { useEffect(() => { if (!relationFeedback.success) return undefined + // Le message de la démo qui porte l'invitation (lien vers l'inscription) reste le temps de le lire et de cliquer const timeout = setTimeout(() => { setRelationFeedback((current) => ({ ...current, success: '' })) - }, 2500) + }, typeof relationFeedback.success === 'string' ? 2500 : 6000) return () => clearTimeout(timeout) }, [relationFeedback.success]) @@ -1485,6 +1502,7 @@ function App() { editMode.deactivate() setContribSubmitError('') setShowContribModal(false) + if (signupPrompts) setDraftNotice(demoSignupMessage(t('Votre essai reste dans la démo.'))) return } @@ -2122,7 +2140,7 @@ function App() { const addedRelations = [...(draft.addedRelations || []), { sourcePersonId, targetPersonId, relationType }] editMode.updateDraft({ addedRelations }) tree.bumpGraphRevision() - setRelationFeedback({ loading: false, success: t('Lien familial ajouté (démo locale)'), error: '' }) + setRelationFeedback({ loading: false, success: signupPrompts ? demoSignupMessage(t('Lien ajouté à la démo.')) : t('Lien familial ajouté (démo locale)'), error: '' }) return } @@ -2187,7 +2205,7 @@ function App() { if (isDemoMode) { for (const link of childLinks) link.parentageType = parentageType tree.bumpGraphRevision() - setRelationFeedback({ loading: false, success: t('Lien modifié (démo locale)'), error: '' }) + setRelationFeedback({ loading: false, success: signupPrompts ? demoSignupMessage(t('Lien modifié dans la démo.')) : t('Lien modifié (démo locale)'), error: '' }) return } @@ -2264,7 +2282,7 @@ function App() { const removedRelations = [...(draft.removedRelations || []), { sourcePersonId, targetPersonId, relationType }] editMode.updateDraft({ removedRelations }) tree.bumpGraphRevision() - setRelationFeedback({ loading: false, success: t('Lien familial supprimé (démo locale)'), error: '' }) + setRelationFeedback({ loading: false, success: signupPrompts ? demoSignupMessage(t('Lien retiré de la démo.')) : t('Lien familial supprimé (démo locale)'), error: '' }) return } @@ -2416,6 +2434,8 @@ function App() { // Explicit demo modes (landing iframe / forced demo) if (query.has('embed') || query.has('demo')) { tree.handleUseDemo() + // Sans attendre : la démo s'ouvre tout de suite, les options (inscription, invitations) arrivent ensuite + fetchAuthOptions().then((options) => { if (active && options) setAuthOptions(options) }).catch(() => {}) return } @@ -2605,7 +2625,7 @@ function App() { // ?account=register (CTA de la landing) : écran compte directement en création if (query.get('account') === 'register' && registrationAllowed) { setAccountEntryMode('register') - trackAppEvent('account_screen_viewed', { mode: 'register', from_shared_tree: false, source: getSource() }) + trackAppEvent('account_screen_viewed', { mode: 'register', from_shared_tree: false, via: 'lien', source: getSource() }) } tree.setBootState('account') } @@ -2648,8 +2668,14 @@ function App() { } }, [tree.bootState, auth.authenticated, auth.accountLoading, auth.accountTrees.length, accountFromTree]) // eslint-disable-line react-hooks/exhaustive-deps - const handleUseAccount = (mode = 'login') => { + const ownTreeAfterSignupRef = useRef(false) + // via : la porte qui a mené à l'écran de compte (ajouter, compte, demo), pour les mesures + const handleUseAccount = (mode = 'login', via = 'autre') => { const ctx = tree.treeContext + // « Faire le mien » : après l'inscription, l'assistant de création s'ouvre au-dessus de l'arbre de la famille + ownTreeAfterSignupRef.current = mode === 'register' && via === 'ajouter' + // Inscription depuis un arbre reçu ou depuis la démo, sans source dans l'adresse : on le note + if (mode === 'register' && ctx.treeId && ctx.accessMode !== 'member') rememberSource(ctx.accessMode === 'demo' ? 'demo' : 'arbre-partage') pendingSharedTreeRef.current = ctx.treeId && ctx.accessMode !== 'demo' && ctx.accessMode !== 'member' ? { treeId: ctx.treeId, @@ -2666,7 +2692,7 @@ function App() { setAccountPanelVisible(false) const entryMode = mode === 'register' && registrationOpen ? 'register' : 'login' setAccountEntryMode(entryMode) - trackAppEvent('account_screen_viewed', { mode: entryMode, from_shared_tree: Boolean(pendingSharedTreeRef.current?.treeId) }) + trackAppEvent('account_screen_viewed', { mode: entryMode, from_shared_tree: Boolean(pendingSharedTreeRef.current?.treeId), via, source: getSource() }) media.resetMediaManager() setAdminPanelVisible(false) contrib.resetContribState() @@ -2714,7 +2740,7 @@ function App() { const entry = trees.find((candidate) => String(candidate.id) === String(pending.treeId)) if (entry) { await openAccountTree(entry, token, { promptWizard: false }) - return true + return 'opened' } } catch { // Jeton expiré ou refusé : on repasse par le mot de passe @@ -2724,7 +2750,7 @@ function App() { tree.setTreeContext(buildEmptyTreeContext(pending)) tree.setGateError('') tree.setBootState('need-access') - return true + return 'gate' } finally { pendingSharedTreeRef.current = null setPendingSharedTreeName('') @@ -2776,7 +2802,11 @@ function App() { trackAppEvent('account_registered', { from_shared_tree: Boolean(pendingSharedTreeRef.current?.treeId), source: getSource() }) const token = auth.restoreUserToken() - if (token && await resumePendingSharedTree(token)) { + const resumed = token ? await resumePendingSharedTree(token) : false + if (resumed) { + // L'arbre de la famille est rattaché au compte et rouvert ; « Faire le mien » ouvre aussi l'assistant + if (resumed === 'opened' && ownTreeAfterSignupRef.current) setTreeWizardVisible(true) + ownTreeAfterSignupRef.current = false return } @@ -3387,6 +3417,7 @@ function App() { onContinue={handleConfirmContinue} onSaveAndQuit={handleConfirmSaveAndQuit} onDiscard={handleConfirmDiscard} + demo={isDemoMode} /> {/* Slide-in Ajouter Personne */} setAccountPanelVisible(true)} onSettingsClick={() => setAdminPanelVisible(true)} onLogout={handleLogout} @@ -3635,6 +3667,7 @@ function App() { onRotatePasswords={handleContributionPasswordRotate} onRememberPassword={handleContributionPasswordRemember} onReviewSession={contrib.handleReviewContributionSession} + seasonalInvite={signupPrompts} /> { const [decisionMap, setDecisionMap] = useState({}) const [collapsedSessions, setCollapsedSessions] = useState(new Set()) @@ -355,7 +342,7 @@ const ContributionPanel = ({ }, [inviteKnownPasswords?.share, visible]) const knownShare = inviteKnownPasswords?.share || '' - const defaultInviteText = knownShare && inviteShareUrl ? buildInviteText(inviteShareUrl, knownShare) : '' + const defaultInviteText = knownShare && inviteShareUrl ? buildInviteText(inviteShareUrl, knownShare, { seasonal: seasonalInvite }) : '' const [inviteText, setInviteText] = useState('') useEffect(() => { setInviteText(defaultInviteText) }, [defaultInviteText]) diff --git a/frontend/src/components/EditConfirmModal.jsx b/frontend/src/components/EditConfirmModal.jsx index 8172345..fa696b0 100644 --- a/frontend/src/components/EditConfirmModal.jsx +++ b/frontend/src/components/EditConfirmModal.jsx @@ -12,7 +12,8 @@ import { t } from '../i18n/index.js' * - Enregistrer brouillon et quitter * - Abandonner les modifications */ -function EditConfirmModal({ visible, onContinue, onSaveAndQuit, onDiscard }) { +// demo : la démo ne garde rien sur l'appareil, ses modifications vivent le temps de la page +function EditConfirmModal({ visible, onContinue, onSaveAndQuit, onDiscard, demo = false }) { const continueRef = useRef(null) // Focus sur "Continuer" à l'ouverture + trap ESC @@ -47,7 +48,9 @@ function EditConfirmModal({ visible, onContinue, onSaveAndQuit, onDiscard }) {

{t('Modifications en cours')}

{titleBefore}{t("l'édition")}{titleAfter}

-

{t('Vos dernières modifications ne sont pas encore enregistrées. Elles restent sur cet appareil tant que vous ne les abandonnez pas.')}

+

{demo + ? t('Vos dernières modifications restent dans la démo tant que cette page est ouverte, puis elles disparaissent.') + : t('Vos dernières modifications ne sont pas encore enregistrées. Elles restent sur cet appareil tant que vous ne les abandonnez pas.')}

diff --git a/frontend/src/components/SettingsPanels.css b/frontend/src/components/SettingsPanels.css index 7afe13b..644e430 100644 --- a/frontend/src/components/SettingsPanels.css +++ b/frontend/src/components/SettingsPanels.css @@ -292,6 +292,8 @@ .undo-toast-btn::before { content: ''; position: absolute; inset: -8px; } .undo-toast-btn:hover { background: color-mix(in srgb, var(--pz-bg) 26%, transparent); } .undo-toast-btn:focus-visible { outline: 2px solid var(--pz-bg); outline-offset: 2px; } +/* Le lien d'une invitation (démo) reste cliquable, le reste de la bulle laisse passer les clics */ +.contrib-success-toast a { pointer-events: auto; } @media (max-width: 768px) { .contrib-success-toast { bottom: 78px; padding: 11px 16px; border-radius: 16px; font-size: 13px; } } @media (max-width: 560px) { diff --git a/frontend/src/components/navbar/ContextualNavbar.jsx b/frontend/src/components/navbar/ContextualNavbar.jsx index 7dec10b..0fa2d43 100644 --- a/frontend/src/components/navbar/ContextualNavbar.jsx +++ b/frontend/src/components/navbar/ContextualNavbar.jsx @@ -1,6 +1,6 @@ import { useState, useRef, useEffect } from 'react' import { - Brush, ImagePlus, Plus, UserPlus, User, MousePointer2, Pencil, Type, Star, Trash2, Image, ArrowLeft, Search, + Brush, ImagePlus, Plus, UserPlus, User, MousePointer2, Pencil, Type, Star, Trash2, Image, ArrowLeft, Search, TreeDeciduous, } from 'lucide-react' import { NavMenu, NavMenuGroup, NavMenuItem } from './NavMenu' import AvatarMenu from './AvatarMenu' @@ -54,6 +54,7 @@ function ContextualNavbar({ onCreateTree, onProfileClick, canRegister = true, + signupPrompts = false, onAccountClick, onSettingsClick, onLogout, @@ -148,9 +149,10 @@ function ContextualNavbar({ } // Création de compte dans l'app - const handleCreateAccountClick = () => { - trackAppEvent('create_account_clicked', accountClickProps) - onProfileClick?.('register') + // via : « ajouter » (menu Ajouter de la famille) ou « compte » (menu du compte) + const handleCreateAccountClick = (via = 'compte') => { + trackAppEvent('create_account_clicked', { ...accountClickProps, via }) + onProfileClick?.('register', via) } const handleAnnotToolClick = (toolId) => { @@ -223,6 +225,12 @@ function ContextualNavbar({ addGroups.push({ label: t('Chercher'), items: [{ key: 'search', icon: , label: t("Quelqu'un ailleurs"), sub: t('décès depuis 1970, archives, presse'), onSelect: onSearchClick }] }) } } + // La famille sans compte trouve « Faire le mien » en bas du menu qu'elle ouvre, le menu Ajouter, et la ligne quitte + // le menu du compte. Seulement si l'instance invite à créer son arbre (SIGNUP_PROMPTS). + const ownTreeInAddMenu = signupPrompts && canRegister && isFamily && !authenticated && !showMobileAnnot + if (ownTreeInAddMenu) { + addGroups.push({ label: t('Votre famille'), items: [{ key: 'own-tree', icon: , label: t('Faire le mien'), sub: t('un arbre pour votre famille, gratuit'), onSelect: () => handleCreateAccountClick('ajouter') }] }) + } const hasAddMenu = addGroups.length > 0 const renderAddMenu = () => ( @@ -420,8 +428,8 @@ function ContextualNavbar({ onSelectTree={onTreeSelect} onCreateTree={onCreateTree} onSettings={onSettingsClick} - onLogin={() => onProfileClick?.('login')} - onRegister={canRegister ? handleCreateAccountClick : undefined} + onLogin={() => onProfileClick?.('login', 'compte')} + onRegister={canRegister && !ownTreeInAddMenu ? () => handleCreateAccountClick('compte') : undefined} onLogout={onLogout} /> )} diff --git a/frontend/src/i18n/en/app.js b/frontend/src/i18n/en/app.js index 6c4f783..1ce2134 100644 --- a/frontend/src/i18n/en/app.js +++ b/frontend/src/i18n/en/app.js @@ -34,6 +34,16 @@ export default { // App.jsx : liens familiaux 'Lien déjà présent': 'This link already exists', 'Lien familial ajouté (démo locale)': 'Family link added (local demo)', + // Invitations à créer son arbre (SIGNUP_PROMPTS) + 'Lien ajouté à la démo.': 'Link added to the demo.', + 'Lien modifié dans la démo.': 'Link changed in the demo.', + 'Lien retiré de la démo.': 'Link removed from the demo.', + 'Votre essai reste dans la démo.': 'Your changes stay in the demo.', + 'Pour votre famille,': 'For your own family,', + 'créez votre arbre': 'create your tree', + 'Votre famille': 'Your family', + 'Faire le mien': 'Make my own', + 'un arbre pour votre famille, gratuit': 'a tree for your family, free', 'Lien ajouté à la contribution': 'Link added to your contribution', 'Lien familial ajouté': 'Family link added', 'Lien modifié (démo locale)': 'Link changed (local demo)', diff --git a/frontend/src/i18n/en/contribution.js b/frontend/src/i18n/en/contribution.js index 6bdc980..de47584 100644 --- a/frontend/src/i18n/en/contribution.js +++ b/frontend/src/i18n/en/contribution.js @@ -41,6 +41,8 @@ export default { // ─── Message d'invitation ─── 'Bonjour à tous,': 'Hi everyone,', + "Pour Noël, j'ai mis l'arbre de notre famille en ligne. Regardez-le avant le réveillon, et apportez vos vieilles photos, on le complétera ensemble à table.": + "For Christmas, I put our family tree online. Have a look before Christmas Eve, and bring your old photos, we'll fill it in together at the table.", "Je rassemble l'histoire de notre famille dans un arbre en ligne, et j'aimerais que vous m'aidiez à le compléter.": "I'm putting our family's history together in an online tree, and I'd love your help filling it in.", "L'arbre est ici {url}": 'The tree is here {url}', @@ -171,6 +173,8 @@ export default { 'Modifications en cours': 'Unsaved changes', 'Quitter {em} ?': 'Leave {em}?', "l'édition": 'edit mode', + 'Vos dernières modifications restent dans la démo tant que cette page est ouverte, puis elles disparaissent.': + 'Your latest changes stay in the demo while this page is open, then they are gone.', 'Vos dernières modifications ne sont pas encore enregistrées. Elles restent sur cet appareil tant que vous ne les abandonnez pas.': "Your latest changes aren't saved yet. They stay on this device until you discard them.", 'Abandonner': 'Discard', diff --git a/frontend/src/styles/pz.css b/frontend/src/styles/pz.css index c65ee73..d814e97 100644 --- a/frontend/src/styles/pz.css +++ b/frontend/src/styles/pz.css @@ -83,6 +83,7 @@ p, li, blockquote, figcaption, dd, .pz-sub, .pz-small, .pz-hint, .pz-error, .pz- .pz-btn--danger-ghost:hover:not(:disabled) { background: var(--pz-danger-bg); } } .pz-link { background: none; border: 0; padding: 0; cursor: pointer; font: 500 14px var(--pz-sans); color: var(--pz-a700); text-decoration: underline; text-underline-offset: 3px; } +.pz-inline-link { color: inherit; font-weight: 500; text-decoration: underline; text-underline-offset: 3px; } /* ── Champs ───────────────────────────────────────────────────── */ .pz-field { display: grid; gap: 7px; } diff --git a/frontend/src/utils/analytics.js b/frontend/src/utils/analytics.js index 1adf449..86af170 100644 --- a/frontend/src/utils/analytics.js +++ b/frontend/src/utils/analytics.js @@ -57,9 +57,10 @@ export function scrubAnalyticsEvent(event) { // La première source connue gagne, « direct » ne remplace jamais une vraie source. const SOURCE_KEY = 'parenthese_source' -export function rememberSource() { +// fallback : source donnée par l'app quand l'adresse n'en porte pas (arbre-partage, demo), au même rang +export function rememberSource(fallback = '') { try { - const fromUrl = new URLSearchParams(window.location.search).get('source') + const fromUrl = new URLSearchParams(window.location.search).get('source') || fallback const clean = String(fromUrl || '').trim().toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 40) if (clean && clean !== 'direct' && !localStorage.getItem(SOURCE_KEY)) { localStorage.setItem(SOURCE_KEY, clean) diff --git a/frontend/src/utils/inviteText.js b/frontend/src/utils/inviteText.js new file mode 100644 index 0000000..a0eefd0 --- /dev/null +++ b/frontend/src/utils/inviteText.js @@ -0,0 +1,24 @@ +import { t } from '../i18n/index.js' +import { HELP_URL } from './siteLinks.js' + +// Du 1er décembre au 6 janvier, la famille se retrouve : le message d'invitation parle de Noël +export function isChristmasSeason(now = new Date()) { + return now.getMonth() === 11 || (now.getMonth() === 0 && now.getDate() <= 6) +} + +// Message d'invitation prêt à envoyer par mail, SMS ou WhatsApp. seasonal : l'instance l'autorise (SIGNUP_PROMPTS) +export function buildInviteText(url, password, { seasonal = false, now = new Date() } = {}) { + return [ + t('Bonjour à tous,'), + '', + seasonal && isChristmasSeason(now) + ? t("Pour Noël, j'ai mis l'arbre de notre famille en ligne. Regardez-le avant le réveillon, et apportez vos vieilles photos, on le complétera ensemble à table.") + : t("Je rassemble l'histoire de notre famille dans un arbre en ligne, et j'aimerais que vous m'aidiez à le compléter."), + '', + t("L'arbre est ici {url}", { url }), + t("Le mot de passe pour l'ouvrir est {password}", { password }), + '', + t("En bas de l'écran, vous pouvez ajouter une photo, un souvenir ou une personne qui manque."), + t("Le mode d'emploi, si besoin {url}", { url: HELP_URL }), + ].join('\n') +} diff --git a/frontend/src/utils/inviteText.test.js b/frontend/src/utils/inviteText.test.js new file mode 100644 index 0000000..15f71d2 --- /dev/null +++ b/frontend/src/utils/inviteText.test.js @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { buildInviteText, isChristmasSeason } from '../utils/inviteText.js' + +// Message d'invitation de Noël (SIGNUP_PROMPTS) : du 1er décembre au 6 janvier, et seulement si l'instance l'autorise +describe("message d'invitation de Noël", () => { + const at = (iso) => new Date(`${iso}T12:00:00`) + + it('couvre du 1er décembre au 6 janvier inclus', () => { + expect(isChristmasSeason(at('2026-11-30'))).toBe(false) + expect(isChristmasSeason(at('2026-12-01'))).toBe(true) + expect(isChristmasSeason(at('2027-01-06'))).toBe(true) + expect(isChristmasSeason(at('2027-01-07'))).toBe(false) + }) + + it('parle de Noël en décembre quand l\'instance l\'autorise', () => { + const text = buildInviteText('https://exemple.test/arbre/x', 'lilas-1928', { seasonal: true, now: at('2026-12-15') }) + expect(text).toContain('Pour Noël') + expect(text).toContain('https://exemple.test/arbre/x') + expect(text).toContain('lilas-1928') + }) + + it('garde le message habituel hors saison ou sur une instance qui ne l\'autorise pas', () => { + expect(buildInviteText('u', 'p', { seasonal: true, now: at('2026-11-15') })).toContain('Je rassemble') + expect(buildInviteText('u', 'p', { seasonal: false, now: at('2026-12-15') })).toContain('Je rassemble') + }) +})