Invitations à créer son arbre, coupées en auto-hébergement (SIGNUP_PROMPTS) (#202)

- La famille sans compte trouve « Faire le mien » en bas du menu Ajouter ; la ligne quitte le menu du
  compte. Après l'inscription, l'arbre de la famille est rattaché et l'assistant de création s'ouvre.
- Démo : les messages des liens et la bulle après « Enregistrer et quitter » proposent l'inscription
  (lien cliquable, message gardé 6 s). La démo lit aussi les options de l'instance.
- Message d'invitation de Noël du 1er décembre au 6 janvier.
- SIGNUP_PROMPTS (API, /auth/options.signupPrompts) : éteint par défaut, allumé pour l'instance hébergée
  dans ecosystem.config.cjs, toujours coupé quand l'inscription est fermée. README, .env.example, OpenAPI.
- Mesures : via sur create_account_clicked et account_screen_viewed, source arbre-partage ou demo
  quand l'adresse n'en porte pas.
- « Quitter l'édition ? » dans la démo ne dit plus que les modifications restent sur l'appareil :
  la démo n'enregistre rien et tout disparaît au rechargement.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 authored and GitHub committed 2026-10-31 18:17:59 +01:00
1 parent 473414744d
commit e596887a44
21 files changed
+193 -47

No files matched your search

+4
View File
@@ -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 +) :
+2
View File
@@ -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.
+2
View File
@@ -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.
+3
View File
@@ -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=""
+4 -1
View File
@@ -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:
+5
View File
@@ -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(''),
+10 -5
View File
@@ -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(
+1 -1
View File
@@ -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 () => {
+22 -2
View File
@@ -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)
})
})
+3
View File
@@ -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',
+45 -12
View File
@@ -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) => (
<span>
{lead} {t('Pour votre famille,')}{' '}
<a
href="/?account=register&source=demo"
className="pz-inline-link"
onClick={(event) => { event.preventDefault(); handleUseAccount('register', 'demo') }}
>{t('créez votre arbre')}</a>.
</span>
)
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 */}
<AddPersonPanel
@@ -3467,6 +3498,7 @@ function App() {
onCreateTree={handleOpenTreeWizard}
onProfileClick={handleUseAccount}
canRegister={registrationOpen}
signupPrompts={signupPrompts}
onAccountClick={() => setAccountPanelVisible(true)}
onSettingsClick={() => setAdminPanelVisible(true)}
onLogout={handleLogout}
@@ -3635,6 +3667,7 @@ function App() {
onRotatePasswords={handleContributionPasswordRotate}
onRememberPassword={handleContributionPasswordRemember}
onReviewSession={contrib.handleReviewContributionSession}
seasonalInvite={signupPrompts}
/>
<SearchSourcesPanel
visible={searchPanelVisible}
+3 -16
View File
@@ -7,6 +7,7 @@ import './SettingsPanels.css'
import PzBusy from './PzBusy.jsx'
import { HELP_URL } from '../utils/siteLinks.js'
import { t, dateLocale } from '../i18n/index.js'
import { buildInviteText } from '../utils/inviteText.js'
import { sexLabel } from '../utils/personSex.js'
import Skeleton from './Skeleton.jsx'
@@ -217,21 +218,6 @@ function formatSessionDate(value) {
})
}
// Message d'invitation prêt à envoyer par mail, SMS ou WhatsApp
function buildInviteText(url, password) {
return [
t('Bonjour à tous,'),
'',
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')
}
// ─── component ──────────────────────────────────────────────────────────────
const ContributionPanel = ({
@@ -254,6 +240,7 @@ const ContributionPanel = ({
onRotatePasswords,
onRememberPassword,
onReviewSession,
seasonalInvite = false,
}) => {
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])
+5 -2
View File
@@ -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 }) {
<div className="pz-modal-head">
<p className="pz-eyebrow">{t('Modifications en cours')}</p>
<h2 id="edit-confirm-title" className="pz-title">{titleBefore}<em>{t("l'édition")}</em>{titleAfter}</h2>
<p className="pz-sub">{t('Vos dernières modifications ne sont pas encore enregistrées. Elles restent sur cet appareil tant que vous ne les abandonnez pas.')}</p>
<p className="pz-sub">{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.')}</p>
</div>
<div className="pz-modal-actions pz-modal-actions--split">
@@ -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) {
@@ -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: <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: <TreeDeciduous {...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}
/>
)}
+10
View File
@@ -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)',
+4
View File
@@ -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',
+1
View File
@@ -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; }
+3 -2
View File
@@ -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)
+24
View File
@@ -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')
}
+26
View File
@@ -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')
})
})