mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
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:
1 parent
473414744d
commit
e596887a44
21 files changed
+193
-47
No files matched your search
@@ -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 +) :
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(''),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user