Compare commits
3 Commits
codex/home
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
360e58e473 | ||
|
|
6fa12f6e13 | ||
|
|
cae464be4f |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -38,6 +38,15 @@ npm-debug.log*
|
||||
|
||||
# Uploads (user data)
|
||||
memento-note/public/uploads/
|
||||
memento-note/data/uploads/
|
||||
|
||||
# Local promo captures (not product source)
|
||||
promo-video/
|
||||
memento-note/public/promo-memento-*.mp4
|
||||
memento-note/public/memento-youtube-thumbnail.png
|
||||
|
||||
# Generated BMAD skill snapshots
|
||||
_bmad/render/
|
||||
|
||||
# Test
|
||||
coverage/
|
||||
@@ -53,3 +62,4 @@ next-env.d.ts
|
||||
|
||||
# Monitoring secrets (generate at deploy)
|
||||
monitoring/metrics-token
|
||||
.gstack/
|
||||
|
||||
295
docs/plan-admin-marketing-statistiques-coupons.md
Normal file
295
docs/plan-admin-marketing-statistiques-coupons.md
Normal file
@@ -0,0 +1,295 @@
|
||||
# Memento — Plan administration : marketing, statistiques et offres Stripe
|
||||
|
||||
Date : 6 septembre 2026.
|
||||
Statut : plan en cours. M1 livré (pages de travail). M2 livré le 6 septembre 2026 (consentement explicite, désabonnement sans connexion, comptes existants non inscrits). Aucun envoi, aucun coupon.
|
||||
Demande : examiner l'administration et préparer un plan détaillé avant toute implémentation.
|
||||
Périmètre de cette session : lectures du code, consultation de documentation officielle, observation locale de `/admin` et `/admin/billing`, rédaction de ce document. Aucun courriel envoyé, aucun coupon créé, aucune donnée modifiée.
|
||||
|
||||
## 1. Objectif et décisions proposées
|
||||
|
||||
Permettre à un administrateur de préparer des courriels de relance ou de présentation de Memento, choisir leurs destinataires, joindre une offre commerciale réellement applicable dans Stripe, vérifier le message, envoyer une campagne et mesurer son résultat.
|
||||
|
||||
Ajouter deux entrées à l'administration :
|
||||
- **Marketing** : campagnes, audiences, modèles de courriel, offres et préférences d'envoi.
|
||||
- **Statistiques** : revenus, abonnements, activité produit et résultats des campagnes.
|
||||
|
||||
Conserver **Facturation & quotas** comme référence pour les tarifs, les identifiants de prix et les limites IA. Les offres ne constituent pas un deuxième catalogue tarifaire.
|
||||
|
||||
Proposition de première version : campagnes manuelles, brouillons assistés par IA, test obligatoire, réductions en pourcentage, destinataires déjà inscrits et autorisés à recevoir de la prospection. Ajouter ensuite les montants fixes et les relances planifiées. Aucune séquence automatique active par défaut.
|
||||
|
||||
### Références fournies
|
||||
|
||||
Les deux captures de l'utilisateur représentent une autre application. Elles expriment des capacités souhaitées, pas une consigne de copier ses chiffres, sa marque ou ses activités.
|
||||
- Capture marketing : génération IA, audience, réduction, sujet, contenu, aperçu, test et historique.
|
||||
- Capture statistiques : période, revenus, abonnements et usage.
|
||||
- Fichiers source des captures : `/home/devparsa/.codex/attachments/d3ab1333-8c84-4124-af46-d4dd77f73aab/codex-clipboard-04125874-6e48-421f-970c-ffc9b3641a13.png` et `/home/devparsa/.codex/attachments/3147e261-0b19-4103-a242-8d1ad26e2720/codex-clipboard-b9c36100-7ca2-4c48-83f2-00255b6e710c.png`.
|
||||
|
||||
Ne pas reprendre « traductions », « liste d'attente » ou d'autres fonctions sans équivalent confirmé dans Memento. Préférer une interface de travail structurée à une accumulation de grandes cartes vides.
|
||||
|
||||
## 2. État actuel vérifié
|
||||
|
||||
| Élément | Présent | Conséquence |
|
||||
| --- | --- | --- |
|
||||
| Navigation admin | `memento-note/components/admin-sidebar.tsx` : accueil, utilisateurs, IA, facturation, publications, paramètres | Ajouter deux destinations sans réorganiser toute l'administration |
|
||||
| Accueil admin | `app/(admin)/admin/page.tsx` : utilisateurs, notes, carnets, agents, abonnements et usage | Garder cette synthèse ; fournir les analyses détaillées dans Statistiques |
|
||||
| Facturation | `app/(admin)/admin/billing/`, `app/actions/admin-billing.ts` | Réutiliser configuration métier en base et contrôles administrateur |
|
||||
| Paiement abonnement | `app/api/billing/create-checkout/route.ts` | `allow_promotion_codes: true` existe dans les deux modes de paiement ; pas de parcours de campagne dans cette route |
|
||||
| Stripe | `lib/stripe.ts` ; version API déclarée `2026-04-22.dahlia` | Vérifier les types de la version installée avant d'écrire les paramètres de coupons |
|
||||
| Notifications Stripe | `app/api/billing/webhook/route.ts` | Gère notamment abonnement, fin d'essai et échec de paiement ; compléter le journal financier et la déduplication si absents |
|
||||
| Courriels | `lib/mail.ts` : Resend et SMTP, identifiant de message, repli automatique entre fournisseurs | Ne pas utiliser le repli aveugle pour les campagnes : un délai dépassé peut masquer un envoi déjà accepté |
|
||||
| Rappel d'essai | `lib/billing/trial-reminder-email.ts` | Éviter les doublons avec les futures relances |
|
||||
| Données | `User`, `Subscription`, `UsageLog`, `AiConsentLog`, `AuditLog` dans `prisma/schema.prisma` | Étendre les modèles existants avec migrations additives |
|
||||
| Historique d'usage | `UsageLog` agrège par utilisateur, fonction et période | Insuffisant pour reconstruire honnêtement un usage quotidien historique |
|
||||
| Consentement marketing | Non identifié dans les modèles examinés | Ne pas assimiler consentement IA, compte vérifié ou abonnement à un consentement marketing |
|
||||
| Dernière activité et langue marketing | Pas de champs dédiés identifiés sur `User` | Définir des sources fiables avant les segments « inactifs » et les envois multilingues |
|
||||
|
||||
Les valeurs vues localement ne décrivent pas la production. Ne pas présenter les compteurs locaux comme chiffres commerciaux.
|
||||
L'accueil actuel transforme certaines erreurs de lecture en zéro : ne pas reproduire ce comportement dans les nouvelles statistiques.
|
||||
|
||||
## 3. Parcours et composition des écrans
|
||||
|
||||
### 3.1 Marketing : liste des campagnes
|
||||
|
||||
Route proposée : `/admin/marketing`.
|
||||
Tableau avec nom interne, objectif, audience, langue, offre associée, état, date prévue, acceptés par le fournisseur, délivrés et conversions payées.
|
||||
Actions : créer, reprendre un brouillon, dupliquer, consulter les résultats, suspendre les messages restants. Pagination et filtres d'état.
|
||||
Une duplication crée un nouveau brouillon et exige un nouveau test ; elle ne déclenche aucun envoi.
|
||||
|
||||
### 3.2 Préparation d'une campagne
|
||||
|
||||
Route : `/admin/marketing/campaigns/[id]`.
|
||||
Parcours en quatre étapes, brouillon sauvegardé entre les étapes :
|
||||
1. **Objectif et destinataires** : nouveauté produit, découverte d'une fonction, fin d'essai commerciale, retour après inactivité, réabonnement, fidélisation. Définition d'audience et exclusions chiffrées.
|
||||
2. **Message et offre** : sujet, texte d'aperçu de boîte mail, contenu, bouton principal, éventuelle réduction. Éditeur visuel adapté au courriel ; code HTML dans une vue avancée, pas comme outil principal.
|
||||
3. **Aperçu et test** : ordinateur/téléphone, version texte, valeurs de personnalisation fictives, conditions de réduction et pied de page réel. Adresse de test explicite. Test obligatoire pour chaque variante linguistique finale.
|
||||
4. **Validation et envoi** : nombre final, différences depuis la préparation, exclusions, coût d'envoi si connu, échéance de l'offre, résumé de consentement. Commande finale explicite, puis progression et annulation des envois non commencés.
|
||||
|
||||
L'IA propose un brouillon, jamais un envoi. Une correction de sujet, contenu, offre, liens, modèle, expéditeur ou langue invalide le test correspondant.
|
||||
L'audience est revue au moment de l'envoi ; un changement de taille important doit être visible avant confirmation.
|
||||
Éviter les cartes imbriquées et les longs formulaires entièrement ouverts. Aides contextuelles, sauvegarde visible et messages d'erreur au bon champ.
|
||||
|
||||
### 3.3 Audiences
|
||||
|
||||
Audiences de départ : comptes autorisés à recevoir des annonces ; utilisateurs BASIC ; essais en cours/fin prochaine ; abonnés payants par formule et périodicité ; anciens abonnés ; langue connue.
|
||||
La présence d'un compte Stripe ne prouve pas un abonnement payant. Distinguer utilisateurs inscrits, abonnés à la communication et abonnés payants dans les libellés.
|
||||
|
||||
Les segments « inactifs depuis N jours » exigent une mesure d'activité humaine documentée. `User.updatedAt`, les notes créées par des agents et les accès automatiques ne sont pas de bons substituts à une dernière visite.
|
||||
Exclusions : désabonnement marketing, absence de base d'envoi autorisée, plainte, adresse définitivement invalide, compte supprimé, adresse non vérifiée selon politique retenue, plafonnement de fréquence, destinataire incompatible avec l'offre.
|
||||
Afficher les raisons et les nombres, avec détails accessibles uniquement aux administrateurs autorisés. Pas d'import arbitraire de fichiers d'adresses dans la première version.
|
||||
|
||||
### 3.4 Génération assistée
|
||||
|
||||
Champs : objectif, consignes, langue, ton, offre facultative. Fournisseur et modèle issus du catalogue serveur vivant, sélection avancée si utile.
|
||||
Contexte autorisé : description validée de Memento, fonctions réellement livrées, tarifs du catalogue, essai de 7 jours, conditions exactes de l'offre.
|
||||
Interdit : lire le contenu privé des notes pour cibler un message ; inventer des chiffres, témoignages, promesses ou conditions commerciales.
|
||||
Produire une structure validée : sujet, aperçu, blocs de texte, bouton, mentions de l'offre. Générer ensuite le HTML avec un modèle maîtrisé et une version texte.
|
||||
Échapper les personnalisations, limiter taille et liens, neutraliser scripts/événements HTML, isoler l'aperçu. Les consignes saisies ne peuvent pas déclencher d'outil d'envoi ou de création Stripe.
|
||||
Créer une fonction IA dédiée `marketing_email` dans le mécanisme existant d'entitlements et réservation atomique ; quota administratif configurable dans Facturation. Réserver avant l'appel, sans remboursement automatique sur échec, et notifier le compteur d'usage. Ne pas facturer silencieusement les destinataires.
|
||||
|
||||
## 4. Offres et coupons Stripe
|
||||
|
||||
### 4.1 Modèle métier
|
||||
|
||||
Un **coupon Stripe** définit la réduction. Un **code promotionnel Stripe** la distribue avec ses restrictions. Une **offre Memento** relie campagne, conditions, coupon/code et suivi.
|
||||
Ne pas créer une remise uniquement dans le courriel ou le navigateur.
|
||||
|
||||
Champs de l'offre : nom interne, réduction en pourcentage ou montant/devise, formules admissibles, mensualité/annualité, date limite d'activation avec fuseau affiché, durée d'application, maximum d'utilisations, bénéficiaires et état de synchronisation.
|
||||
Séparer clairement **date limite pour profiter de l'offre** et **durée de la réduction après activation**.
|
||||
Montants en unités monétaires entières, pas en nombres flottants. Une offre publiée est versionnée ; on ne modifie pas rétroactivement les conditions des messages déjà envoyés.
|
||||
|
||||
### 4.2 Offre de départ recommandée
|
||||
|
||||
« 20 % de réduction sur la prochaine facture mensuelle admissible du forfait Pro », réservée à une audience précise, avec date d'expiration et un usage par bénéficiaire.
|
||||
Préférer une durée bornée à une réduction permanente proposée par défaut. Ne pas écrire « pendant 7 jours » pour confondre validité du lien et durée de la remise.
|
||||
Pour un forfait annuel, afficher le total annuel réduit et l'équivalent mensuel ; préciser que la réduction porte sur une facture annuelle. Ne jamais présenter une réduction annuelle comme un seul mois offert.
|
||||
|
||||
Avec l'essai gratuit de 7 jours, vérifier en mode Stripe test quelle facture consomme la remise, notamment une facture initiale à zéro. Ne promettre « première facture payante » qu'après validation du comportement de la version d'API utilisée. La restriction Stripe « premier achat » peut exclure une personne ayant déjà démarré un essai : ne pas l'activer aveuglément pour une relance de fin d'essai.
|
||||
|
||||
### 4.3 Distribution recommandée
|
||||
|
||||
Deux modes : code partagé pour une campagne publique ; code individuel restreint au client Stripe pour une offre ciblée. Préférer le second pour fidélisation et réactivation.
|
||||
Bouton du courriel vers une route Memento, avec jeton opaque et date de validité ; aucun courriel ou identifiant personnel lisible dans l'URL. Le code reste copiable comme solution de secours si prévu.
|
||||
Un simple clic GET, y compris par un robot d'analyse de courriel, ne crée pas de réduction, d'abonnement ou de réservation consommable.
|
||||
Après connexion, vérifier le destinataire, l'offre, la formule et les restrictions. Afficher le prix et les conditions ; appliquer uniquement après action utilisateur explicite.
|
||||
Le lien doit retrouver l'offre après authentification, sans redirection ouverte et sans divulguer les données du bénéficiaire.
|
||||
|
||||
### 4.4 Nouvel abonnement versus abonnement existant
|
||||
|
||||
- **Sans abonnement payant actif** : enrichir la création de session existante avec l'offre validée côté serveur. Choisir soit remise préappliquée, soit saisie libre de code ; vérifier la compatibilité API, ne pas transmettre les deux options sans contrôle. Préserver les deux modes de paiement.
|
||||
- **Abonné actif** : ne pas créer un second abonnement. Prévoir une application contrôlée sur l'abonnement existant, avec aperçu de la prochaine facture, consentement explicite et règles de cumul. La version initiale peut exclure ces personnes des campagnes de conversion, mais le parcours de fidélisation reste une livraison identifiée.
|
||||
- **Annulation programmée / impayé** : expliquer ce que l'offre change ; elle ne réactive pas automatiquement un abonnement et ne règle pas un impayé. Ne pas modifier le calendrier ni les proratisations à l'insu du client.
|
||||
|
||||
Le serveur et Stripe font autorité sur les limites. Une restriction par périodicité doit être contrôlée côté Memento : les restrictions Stripe par produit ne suffisent pas forcément lorsque le même produit porte un prix mensuel et annuel.
|
||||
Séparer strictement mode test et mode réel dans les données, codes, compteurs et interfaces.
|
||||
Réconciliation des objets Stripe modifiés depuis son interface. Désactiver les nouveaux usages sans prétendre retirer les remises déjà appliquées.
|
||||
Création et activation idempotentes ; journal des objets créés et état récupérable si Stripe réussit mais que la sauvegarde locale échoue.
|
||||
|
||||
## 5. Consentement, délivrabilité et envoi fiable
|
||||
|
||||
### 5.1 Préférences
|
||||
|
||||
La première version doit privilégier un consentement marketing explicite, distinct de l'inscription, de l'IA et des courriels nécessaires au service. Les utilisateurs historiques ne sont pas automatiquement inscrits aux campagnes.
|
||||
Prévoir un choix dans les préférences utilisateur et, si validé, à l'inscription, non précoché. Journaliser texte/version, date, source et retrait. Une exception de prospection client ne doit être activée que si ses conditions sont établies ; la seule création d'un compte ne suffit pas. Voir la source CNIL en fin de document.
|
||||
Ne pas envoyer un courriel commercial de « demande de consentement » aux exclus comme contournement.
|
||||
Le désabonnement marketing ne bloque pas vérification de compte, réinitialisation de mot de passe ou notifications indispensables de facturation. Une remise promotionnelle n'est pas ajoutée à ces messages transactionnels pour contourner les règles.
|
||||
|
||||
Lien de désabonnement sans connexion, signé/opaque, indépendant de la durée de l'offre. GET affiche la page de confirmation pour éviter les désabonnements par robots ; POST traite l'action. Ajouter les en-têtes de désabonnement en un clic conformément aux fournisseurs, avec POST sans session pour cette voie spécifique. Recontrôler l'exclusion juste avant chaque envoi.
|
||||
Retrait global marketing immédiat ; plaintes et rebonds définitifs ajoutent une suppression. Une campagne déjà confiée au fournisseur ne peut pas être rappelée : le signaler honnêtement.
|
||||
|
||||
### 5.2 Service d'envoi
|
||||
|
||||
Préférer Resend explicitement pour les campagnes, car les événements de livraison sont exploitables. Garder les messages transactionnels sur le service existant. SMTP reste une option identifiée avec limites de mesure ; ne pas promettre un statut « délivré » sur le seul succès SMTP.
|
||||
Étendre le transport sans casser ses appelants : texte alternatif, en-têtes autorisés, identifiant de campagne, identifiant de message, clé d'idempotence. Fournisseur fixé par exécution.
|
||||
File durable en PostgreSQL pour la vérité des destinataires et états ; Redis peut aider à la limitation et au verrouillage. Réutiliser les pratiques de tâches planifiées du dépôt avant d'ajouter une dépendance. Aucun envoi de masse dans une requête HTTP longue ni simple tâche mémoire.
|
||||
Réservation atomique d'un message, bail limité, reprises avec délai et nombre d'essais borné. État `UNKNOWN` pour un résultat fournisseur ambigu ; ne pas basculer automatiquement sur SMTP. Vérifier le fournisseur avant réémission et tenir compte de sa fenêtre de déduplication.
|
||||
Une clé unique campagne/exécution/destinataire/langue empêche les doublons métier. Un redémarrage ou double clic d'administrateur ne recrée pas les destinataires.
|
||||
Ne pas utiliser BCC pour diffuser à toute une audience. Envoi individualisé, sans exposition d'autres adresses.
|
||||
Rythme progressif, plafond global et par personne configurables, pause manuelle et automatique si plaintes ou erreurs anormales. Seuils configurables, pas de valeurs fournisseur inventées.
|
||||
|
||||
### 5.3 Test et validation serveur
|
||||
|
||||
Version immuable du message : sujet, aperçu, blocs, rendu HTML/texte, modèle, offre, liens, expéditeur et langue. Empreinte calculée côté serveur.
|
||||
Un test accepté par le fournisseur pour cette version autorise une validation humaine ; il ne prouve pas la réception en boîte principale. Le statut doit refléter cette distinction.
|
||||
À l'envoi réel, vérifier la version testée, l'identité administrateur, la validité de l'offre, les variantes de langue et l'état de campagne dans une transition atomique.
|
||||
Les liens individuels sont issus de paramètres contrôlés ; tester leur structure sans consommer une vraie réduction. Toute modification structurelle après test invalide celui-ci.
|
||||
Tests et aperçus ne comptent pas dans les conversions ni statistiques de campagne.
|
||||
|
||||
## 6. Statistiques : indicateurs utiles et définitions
|
||||
|
||||
Route `/admin/stats`. Période globale, comparaison à période précédente de même durée, fuseau explicite, date de dernière synchronisation et actualisation manuelle. Ne pas recopier une période « 30 jours » sur une carte pilotée par « Aujourd'hui ».
|
||||
Sections : **Revenus**, **Abonnements**, **Usage**, **Campagnes**. Quatre à six indicateurs principaux par section, courbes utiles et tableaux détaillables ; pas de grandes zones vides artificielles.
|
||||
|
||||
| Indicateur | Définition et source attendue |
|
||||
| --- | --- |
|
||||
| Encaissements de la période | Paiements effectivement réussis, datés au paiement ; séparés des factures créées et des sessions terminées |
|
||||
| Remboursements | Montants réellement remboursés sur la période ; montrer séparément brut encaissé et net après remboursement, en précisant traitement des taxes/frais |
|
||||
| Revenus récurrents mensuels estimés | Récurrent contractuel actif ramené au mois, annuel / 12 ; exclure crédits ponctuels et essais non payants ; distinguer avant et après remises récurrentes |
|
||||
| Abonnements | Payants actifs, essais, annulations programmées, terminés, impayés ; ne pas mélanger les statuts |
|
||||
| Conversion d'essai | Cohorte d'essais ayant eu le temps d'arriver à échéance, puis premier paiement réussi ; exclure cohorte encore immature |
|
||||
| Résiliations | Abonnements réellement terminés pendant la période / base active au début ; afficher nombre et formule |
|
||||
| Activité humaine | Utilisateurs ayant une action produit définie ; ne pas déduire l'activité de créations automatiques de notes |
|
||||
| Usage IA | Réservations/consommations par fonction, séparées des appels réussis/échoués ; `UsageLog` seul ne donne ni détail quotidien ni taux d'erreur |
|
||||
| Campagnes | Destinataires autorisés, acceptés, délivrés, rebonds, plaintes, désabonnements, clics si autorisés, offres appliquées, paiements attribués |
|
||||
|
||||
Plusieurs devises : jamais additionner sans conversion explicite documentée. Version initiale : tableaux séparés par devise. Aucun MRR calculé en multipliant tous les abonnés par le prix public actuel.
|
||||
Le cumul historique doit afficher sa date de couverture. Ne pas inventer une série quotidienne à partir d'agrégats mensuels. Prévoir import paginé Stripe en lecture seule, reprise et déduplication pour l'historique disponible.
|
||||
Échecs de synchronisation : « indisponible » ou « données au [date] », jamais zéro silencieux. Si dénominateur nul, afficher « non calculable » plutôt que 100 % de réussite.
|
||||
La comparaison ne doit pas mélanger données test et réelles, période complète et période partielle.
|
||||
|
||||
### Attribution commerciale
|
||||
|
||||
Transporter des identifiants opaques de campagne/offre dans les métadonnées de paiement puis les objets nécessaires au rapprochement des factures. Une session terminée avec essai n'est pas un revenu.
|
||||
Version initiale : conversion directe si offre de campagne appliquée et paiement réussi, au plus une première conversion par abonnement/campagne ; présenter séparément nombre d'acheteurs et total de leurs paiements attribués.
|
||||
Déduire les remboursements selon une règle explicitée. Un code partagé peut circuler hors audience : distinguer usages du code et conversions prouvées d'un destinataire.
|
||||
Ouvertures de courriel non prioritaires, tracking désactivé par défaut : elles ne sont pas une preuve fiable de lecture. Les clics peuvent inclure des robots ; ne pas les appeler utilisateurs engagés sans qualification. L'attribution ne prouve pas que la campagne a causé la vente.
|
||||
Budget/coût fournisseur inconnu : ne pas afficher un retour sur investissement ou une marge fictive. Le classement des utilisateurs reste un outil admin agrégé, sans exposer leurs notes privées.
|
||||
|
||||
## 7. Architecture et données proposées
|
||||
|
||||
Les noms suivants sont des propositions, pas des modèles déjà présents. Adapter au schéma réel sans créer de doublons.
|
||||
|
||||
| Modèle | Rôle / contraintes essentielles |
|
||||
| --- | --- |
|
||||
| MarketingPreference + journal | Choix actuel par utilisateur/adresse, langue préférée, preuve/version et historique ; opposition conservée selon politique de rétention |
|
||||
| EmailSuppression | Adresse normalisée ou empreinte compatible avec la politique de données, motif, source et date ; filtrage avant envoi |
|
||||
| MarketingCampaign | Objectif, auteur, segment versionné, statut, programmation, dernière version |
|
||||
| CampaignVersion | Contenu et conditions immuables par langue, empreinte, modèle IA et version de modèle de courriel |
|
||||
| CampaignTest | Version testée, administrateur, adresse de test protégée, résultat fournisseur et validation |
|
||||
| CampaignRun | Audience figée, exclusions, version approuvée, compteurs et progression |
|
||||
| CampaignRecipient | Unicité run/utilisateur/variante ; état, essais, bail, identifiant fournisseur, version rendue, erreurs expurgées |
|
||||
| MarketingOffer | Conditions versionnées, mode Stripe, identifiants coupon/code, statut, limites et dates |
|
||||
| OfferClaim | Bénéficiaire, code individuel éventuel, état de réservation/application et lien abonnement/facture ; unicité anti-double application |
|
||||
| ProviderEvent | Fournisseur + identifiant événement uniques, signature vérifiée, horodatage, traitement et erreurs |
|
||||
| BillingTransaction / projection facture | Identifiants Stripe uniques, devise, montants, paiement/remboursement, campagne/offre et dates ; source financier locale réconciliable |
|
||||
| ProductActivity / agrégat | Événements minimaux nécessaires aux statistiques futures ; aucune note ni consigne privée dans la mesure |
|
||||
|
||||
Index : état/date prévue pour tâches ; run/état pour destinataires ; fournisseur/eventId ; mode Stripe/objet ; user/date pour activité. Pagination des audiences. Durées de conservation configurées et documentées, pas conservation illimitée des contenus et adresses de campagne par défaut.
|
||||
Journal `AuditLog` pour création, validation, envoi, pause, changement d'offre et accès/export sensibles. Ne pas stocker secrets, corps privés ou jetons utilisables dans les journaux.
|
||||
|
||||
États campagne : DRAFT → READY → SCHEDULED ou QUEUED → SENDING → COMPLETED/PARTIAL_FAILED ; PAUSED/CANCELED pour le restant. Une édition après READY crée une nouvelle version et retire l'approbation.
|
||||
État destinataire : PENDING → SENDING → ACCEPTED → DELIVERED ; FAILED/UNKNOWN/SUPPRESSED selon résultat. Les événements arrivent parfois en désordre ; conserver les faits plutôt qu'écraser une plainte par une livraison tardive.
|
||||
|
||||
### Routes/services envisagés
|
||||
|
||||
- Pages sous `app/(admin)/admin/marketing/` et `app/(admin)/admin/stats/`.
|
||||
- Services sous `lib/marketing/` : audiences, contenu, validation, offres, envoi, suppression, attribution.
|
||||
- Actions/API admin : campagnes CRUD, audience-preview, generate, test, approve-send, pause, resume ; offres create/sync/deactivate ; statistiques read/export.
|
||||
- `/api/marketing/unsubscribe` public à jeton ; `/api/marketing/email-webhook` public à signature fournisseur.
|
||||
- `/offers/[token]` pour consulter l'offre, puis endpoint authentifié POST pour l'appliquer ; aucun effet facturation sur GET.
|
||||
- Étendre le webhook Stripe existant, sans second traitement concurrent des mêmes événements. Vérifier la signature sur corps brut, dédupliquer, enregistrer durablement puis traiter/reprendre.
|
||||
- Tâche d'envoi et tâche de réconciliation utilisant le mécanisme cron/worker existant ; protection par secret serveur et verrous.
|
||||
|
||||
Chaque action admin vérifie le rôle côté serveur, y compris aperçu d'audience, génération, export et endpoints appelés directement. Contrôles CSRF/origine adaptés aux sessions, limitations de fréquence et validation Zod. URL publiques depuis configuration serveur approuvée, pas depuis un Host utilisateur non validé. Aucun domaine ou IP codé en dur dans le produit.
|
||||
|
||||
## 8. Livraison par fonctionnalités
|
||||
|
||||
Une fonctionnalité à la fois, vérification et validation utilisateur avant la suivante. Les dépendances de fondation peuvent être développées sans activer les envois réels.
|
||||
|
||||
| Ordre | Livraison | Acceptation principale |
|
||||
| --- | --- | --- |
|
||||
| M1 | Navigation et pages de travail | **Livré** — `/admin/marketing`, `/admin/stats`, menu admin. Tableau campagnes vide honnête ; statistiques « pas encore disponible » (pas de zéro). Fichiers : `admin-sidebar.tsx`, `app/(admin)/admin/marketing/`, `app/(admin)/admin/stats/`, clés i18n `admin.marketing` / `admin.stats`. |
|
||||
| M2 | Préférences et exclusions | **Livré** — consentement facultatif (réglages + inscription, case non cochée), journal, exclusion d’adresse, page `/unsubscribe` (GET confirme, POST agit), POST public un clic. Comptes existants : pas d’inscription automatique. |
|
||||
| M3 | Audiences | Nombre cohérent, exclusions expliquées, segmentation réelle, pas d'utilisation de notes privées |
|
||||
| M4 | Brouillons et éditeur | Sauvegarde, version texte/HTML, aperçu sécurisé, personnalisation contrôlée |
|
||||
| M5 | Génération IA | Modèle serveur, consignes produit validées, langue et réservation de quota, aucun envoi automatique |
|
||||
| M6 | Offres Stripe test | Création/version/synchronisation, 20 %, expiration, limites et séparation test/réel |
|
||||
| M7 | Activation nouvel abonné | Offre validée côté serveur, parcours après login, interaction essai vérifiée, pas de double abonnement |
|
||||
| M8 | Test et validation | Envoi test de version exacte, invalidation après modification, décision finale atomique |
|
||||
| M9 | File et historique | Pause/reprise, absence de doublons métier, erreurs ambiguës gérées, exclusions revérifiées |
|
||||
| M10 | Événements et mesures d'envoi | Signatures, doublons, désordre, rebonds/plaintes, test hors statistiques |
|
||||
| M11 | Statistiques financières | Paiements/remboursements réconciliés, MRR défini, couverture et devises visibles |
|
||||
| M12 | Usage et résultats marketing | Mesures disponibles distinguées des absentes, attribution directe vérifiable |
|
||||
| M13 | Fidélisation abonnés actifs | Aperçu facture, consentement, application sur abonnement existant, cumul/prorata contrôlés |
|
||||
| M14 | Planification et relances | Dates/fuseaux, règles explicites, plafonds, déduplication avec rappels existants, désactivé par défaut |
|
||||
|
||||
Options ultérieures, non autorisées par ce plan : tests de variantes A/B, import de prospects externes, séquences complexes, score automatique de propension, export public, comparaison causale de campagnes. Ne pas les développer spontanément.
|
||||
|
||||
## 9. Vérification et protection des données
|
||||
|
||||
Respecter l'interdiction actuelle d'écrire de nouveaux tests sans demande explicite. Préparer une matrice de vérification manuelle et exécuter les contrôles existants pertinents ; proposer une autorisation ciblée de tests automatisés pour ces mécanismes critiques lors de leur développement, sans la présumer acquise.
|
||||
|
||||
Scénarios indispensables :
|
||||
- compte non administrateur refusé sur chaque endpoint ; aperçu HTML malveillant neutralisé ; liens externes non autorisés rejetés ; données privées absentes du contexte IA ;
|
||||
- absence de consentement, retrait après mise en file, plainte, adresse invalide et dépassement de fréquence ;
|
||||
- version modifiée après test, une langue non testée, offre expirée entre test et envoi ;
|
||||
- double clic, deux workers, arrêt après acceptation fournisseur, événement reçu deux fois ou dans le désordre ;
|
||||
- offre à 20 %, code d'un autre client, code partagé épuisé, mensualité contre annualité, coupon désactivé et ancien lien ;
|
||||
- essai 7 jours, facture à zéro, premier paiement, abonnement actif, annulation programmée, remboursement partiel et plusieurs devises ;
|
||||
- Stripe indisponible, historique incomplet, absence de données, erreur qui ne doit pas devenir zéro ;
|
||||
- français, anglais, persan/RTL, écran de téléphone, clavier, lecture des conditions et liens de désabonnement ;
|
||||
- comparaison des chiffres d'un jeu Stripe test avec son interface, sans utiliser la production comme banc d'essai.
|
||||
|
||||
Ne pas envoyer de vrais courriels, créer de coupons réels ou toucher à un abonnement réel sans autorisation explicite de l'utilisateur portant sur l'action concrète. Cette demande autorise uniquement le plan.
|
||||
Migrations additives et procédure du dépôt. Aucune commande destructive. Ne pas intégrer des migrations au workflow de déploiement en contredisant les règles existantes.
|
||||
|
||||
## 10. Décisions à faire valider avant activation réelle
|
||||
|
||||
1. Expéditeur et adresse de réponse ; Resend recommandé pour les campagnes, transport transactionnel conservé.
|
||||
2. Consentement explicite par défaut et parcours de recueil pour les utilisateurs existants.
|
||||
3. Offre initiale : pourcentage, formule, durée, expiration, population ; aucune remise permanente présélectionnée.
|
||||
4. Traitement de l'essai gratuit et des abonnés déjà actifs, après scénario Stripe test concluant.
|
||||
5. Budget de génération IA et fréquence maximale d'envoi ; paramètres métier dans l'administration.
|
||||
6. Langue préférée : source existante à identifier, stockage explicite si nécessaire ; ne pas deviner à partir du contenu des notes.
|
||||
7. Suivi de clics et durée de conservation ; pas de suivi d'ouverture par défaut.
|
||||
8. Définition finale du MRR et périmètre de l'historique importable ; ne pas annoncer de précision comptable non démontrée.
|
||||
|
||||
Ces décisions ne bloquent pas la lecture du code, la conception des écrans ou la préparation d'une migration additive. Elles bloquent seulement les actions qui dépendent réellement de leur réponse.
|
||||
|
||||
## 11. Sources officielles consultées
|
||||
|
||||
Vérifiées le 6 septembre 2026. Recontrôler les paramètres exacts contre les types Stripe/Resend installés au moment de l'implémentation.
|
||||
- [Stripe : coupons et codes promotionnels](https://docs.stripe.com/billing/subscriptions/coupons) — distinction entre réduction et code, restrictions et application à un abonnement.
|
||||
- [Stripe : réductions dans Checkout](https://docs.stripe.com/payments/checkout/discounts) — parcours de paiement et limites de remise.
|
||||
- [Resend : clés d'idempotence](https://resend.com/docs/dashboard/emails/idempotency-keys) — déduplication fournisseur ; vérifier sa durée de validité lors de l'implémentation.
|
||||
- [Resend : notifications serveur](https://resend.com/docs/webhooks/introduction) et [types d'événements](https://resend.com/docs/webhooks/event-types) — livraison, erreurs et réception potentiellement désordonnée.
|
||||
- [CNIL : prospection par courrier électronique](https://www.cnil.fr/la-prospection-commerciale-par-courrier-electronique) — consentement, opposition et limites de l'exception client. Ce plan retient une politique explicite par défaut, pas une qualification juridique automatique de toute audience.
|
||||
|
||||
## 12. Reprise par un autre agent
|
||||
|
||||
- Lire ce document puis AGENTS.md, PRODUCT.md et le code réellement présent.
|
||||
- Vérifier `git status` et la branche actuelle ; conserver tous les changements préexistants. Ne pas supposer que les anciens constats sont encore valides.
|
||||
- Demander quelle fonctionnalité M1–M14 démarrer si aucune n'a été autorisée. Ne pas transformer la demande de plan en autorisation d'envoi ou d'implémentation complète.
|
||||
- Utiliser des modèles légers pour recherche de fichiers et vérification de clés. Réserver l'analyse approfondie aux garanties d'envoi, aux remises et à la mesure financière.
|
||||
- Chaque livraison met à jour ce document et `docs/user-stories.md` avec état, fichiers, vérifications, limites et prochaine étape. Ne pas marquer « terminé » sans preuve.
|
||||
- Ne jamais recopier de secrets, sessions, adresses privées de test ou mots de passe dans les documents.
|
||||
- Ne pas continuer les précédents chantiers de design de Carnets/éditeur : ils sont hors de ce périmètre.
|
||||
@@ -1,7 +1,7 @@
|
||||
# User Stories — Memento Next Phase
|
||||
|
||||
> Basé sur l'analyse du prototype `architectural-grid/` et du code production `memento-note/`.
|
||||
> Dernière mise à jour : 2026-06-28 (Epic 6 terminé — sync avec sprint-status.yaml)
|
||||
> Dernière mise à jour : 2026-09-06 (M2 consentement annonces + désabonnement)
|
||||
|
||||
---
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
| **US-CHAT-PDF** | Chat with PDF — RAG documentaire | ✅ **LIVRÉ** | `document-qa-overlay.tsx`, `document-ingestion`, `document-search` tool |
|
||||
| **US-PPTX-EXPORT** | Export PPTX + Watermark | ✅ **LIVRÉ** | `lib/brainstorm/export-pptx.ts`, `lib/ai/tools/pptx.tool.ts` |
|
||||
| **US-PUBLISH-IA** | Publication IA (templates magazine/brief/essay + rewrite) | ✅ **LIVRÉ** | `lib/publish/`, `publish-enhance.service.ts`, 4 templates CSS, quota `publish_enhance` |
|
||||
| **M1-ADMIN-MARKETING** | Admin Marketing + Statistiques : pages de travail, menu, aucun envoi ni chiffre fictif | ✅ **LIVRÉ** | `admin-sidebar.tsx`, `app/(admin)/admin/marketing/`, `app/(admin)/admin/stats/`, i18n 15 langues. Plan : `docs/plan-admin-marketing-statistiques-coupons.md` |
|
||||
| **M2-MARKETING-CONSENT** | Consentement explicite aux annonces + désabonnement sans connexion | ✅ **LIVRÉ** | `MarketingPreference`, `EmailSuppression`, réglages généraux, inscription (case non cochée), `/unsubscribe`, `/api/marketing/unsubscribe` |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import { Mail } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
const COLUMN_KEYS = [
|
||||
'name',
|
||||
'goal',
|
||||
'audience',
|
||||
'language',
|
||||
'offer',
|
||||
'status',
|
||||
'scheduledAt',
|
||||
'accepted',
|
||||
'delivered',
|
||||
'paidConversions',
|
||||
] as const
|
||||
|
||||
export function MarketingAdminClient() {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<div className="space-y-6" dir="auto">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail size={20} className="shrink-0 text-brand-accent" aria-hidden />
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||
{t('admin.marketing.title')}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
{t('admin.marketing.description')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
title={t('admin.marketing.createDisabledHint')}
|
||||
className="inline-flex h-10 items-center justify-center rounded-xl border border-border bg-muted/40 px-4 text-sm font-medium text-muted-foreground opacity-70"
|
||||
>
|
||||
{t('admin.marketing.createCampaign')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-border bg-card">
|
||||
<table className="w-full min-w-[56rem] border-collapse text-start text-[13px]">
|
||||
<caption className="sr-only">{t('admin.marketing.title')}</caption>
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-muted/30">
|
||||
{COLUMN_KEYS.map((key) => (
|
||||
<th
|
||||
key={key}
|
||||
scope="col"
|
||||
className="whitespace-nowrap px-3 py-2.5 font-semibold text-foreground"
|
||||
>
|
||||
{t(`admin.marketing.columns.${key}`)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={COLUMN_KEYS.length} className="px-3 py-12 text-center">
|
||||
<p className="font-medium text-foreground">{t('admin.marketing.emptyTitle')}</p>
|
||||
<p className="mx-auto mt-1 max-w-lg text-sm text-muted-foreground">
|
||||
{t('admin.marketing.emptyBody')}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
7
memento-note/app/(admin)/admin/marketing/page.tsx
Normal file
7
memento-note/app/(admin)/admin/marketing/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { MarketingAdminClient } from './marketing-admin-client'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function AdminMarketingPage() {
|
||||
return <MarketingAdminClient />
|
||||
}
|
||||
7
memento-note/app/(admin)/admin/stats/page.tsx
Normal file
7
memento-note/app/(admin)/admin/stats/page.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { StatsAdminClient } from './stats-admin-client'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function AdminStatsPage() {
|
||||
return <StatsAdminClient />
|
||||
}
|
||||
54
memento-note/app/(admin)/admin/stats/stats-admin-client.tsx
Normal file
54
memento-note/app/(admin)/admin/stats/stats-admin-client.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client'
|
||||
|
||||
import { BarChart3 } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: 'revenue', titleKey: 'admin.stats.sectionRevenue' },
|
||||
{ id: 'subscriptions', titleKey: 'admin.stats.sectionSubscriptions' },
|
||||
{ id: 'usage', titleKey: 'admin.stats.sectionUsage' },
|
||||
{ id: 'campaigns', titleKey: 'admin.stats.sectionCampaigns' },
|
||||
] as const
|
||||
|
||||
export function StatsAdminClient() {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<div className="space-y-6" dir="auto">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 size={20} className="shrink-0 text-brand-accent" aria-hidden />
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||
{t('admin.stats.title')}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="mt-1 max-w-2xl text-sm text-muted-foreground">
|
||||
{t('admin.stats.description')}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t('admin.stats.lastSyncUnknown')} · {t('admin.stats.timezoneNote')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{SECTIONS.map((section) => (
|
||||
<section
|
||||
key={section.id}
|
||||
aria-labelledby={`stats-${section.id}`}
|
||||
className="rounded-xl border border-border bg-card px-4 py-4"
|
||||
>
|
||||
<h2 id={`stats-${section.id}`} className="text-sm font-semibold text-foreground">
|
||||
{t(section.titleKey)}
|
||||
</h2>
|
||||
<p className="mt-3 text-sm font-medium text-foreground">
|
||||
{t('admin.stats.unavailable')}
|
||||
</p>
|
||||
<p className="mt-1 text-[13px] leading-relaxed text-muted-foreground">
|
||||
{t('admin.stats.unavailableBody')}
|
||||
</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
121
memento-note/app/(auth)/unsubscribe/page.tsx
Normal file
121
memento-note/app/(auth)/unsubscribe/page.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { CheckCircle2, AlertCircle, Mail } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { confirmMarketingUnsubscribe, previewUnsubscribeToken } from '@/app/actions/marketing-unsubscribe'
|
||||
|
||||
function UnsubscribeContent() {
|
||||
const { t } = useLanguage()
|
||||
const searchParams = useSearchParams()
|
||||
const token = searchParams.get('token') || ''
|
||||
const [status, setStatus] = useState<'idle' | 'saving' | 'ok' | 'already' | 'invalid'>(
|
||||
token ? 'idle' : 'invalid',
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
let cancelled = false
|
||||
void previewUnsubscribeToken(token).then((result) => {
|
||||
if (!cancelled && !result.valid) setStatus('invalid')
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [token])
|
||||
|
||||
const confirm = async () => {
|
||||
if (!token) {
|
||||
setStatus('invalid')
|
||||
return
|
||||
}
|
||||
setStatus('saving')
|
||||
const result = await confirmMarketingUnsubscribe(token)
|
||||
if (result.invalid || !result.ok) setStatus('invalid')
|
||||
else if (result.already) setStatus('already')
|
||||
else setStatus('ok')
|
||||
}
|
||||
|
||||
if (status === 'ok' || status === 'already') {
|
||||
return (
|
||||
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl text-center space-y-6">
|
||||
<div className="w-14 h-14 mx-auto rounded-2xl bg-emerald-500/10 flex items-center justify-center">
|
||||
<CheckCircle2 size={28} className="text-emerald-600" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-serif font-bold">{t('unsubscribe.successTitle')}</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)] leading-relaxed">
|
||||
{t('unsubscribe.successBody')}
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/login" className="block text-sm font-medium text-[var(--color-brand-accent)] hover:underline">
|
||||
{t('unsubscribe.backToSignIn')}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'invalid') {
|
||||
return (
|
||||
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl text-center space-y-6">
|
||||
<div className="w-14 h-14 mx-auto rounded-2xl bg-red-500/10 flex items-center justify-center">
|
||||
<AlertCircle size={28} className="text-red-500" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-serif font-bold">{t('unsubscribe.invalidTitle')}</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)] leading-relaxed">
|
||||
{t('unsubscribe.invalidBody')}
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/settings/general" className="block text-sm font-medium text-[var(--color-brand-accent)] hover:underline">
|
||||
{t('unsubscribe.openSettings')}
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl text-center space-y-6">
|
||||
<div className="w-14 h-14 mx-auto rounded-2xl bg-[var(--color-brand-accent)]/10 flex items-center justify-center">
|
||||
<Mail size={28} className="text-[var(--color-brand-accent)]" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-serif font-bold">{t('unsubscribe.title')}</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)] leading-relaxed">
|
||||
{t('unsubscribe.body')}
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
void confirm()
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === 'saving'}
|
||||
className="w-full bg-[var(--foreground)] text-[var(--background)] py-4 rounded-2xl font-bold uppercase tracking-[0.2em] text-[10px] transition-all hover:shadow-xl active:scale-[0.98] disabled:opacity-50"
|
||||
>
|
||||
{t('unsubscribe.confirm')}
|
||||
</button>
|
||||
<Link
|
||||
href="/home"
|
||||
className="block text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
>
|
||||
{t('unsubscribe.keep')}
|
||||
</Link>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UnsubscribePage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<UnsubscribeContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import { useLanguage } from '@/lib/i18n'
|
||||
import { updateAISettings } from '@/app/actions/ai-settings'
|
||||
import { toast } from 'sonner'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Globe, Bell, Shield, Brain, HelpCircle } from 'lucide-react'
|
||||
import { Globe, Bell, Shield, Brain, HelpCircle, Mail } from 'lucide-react'
|
||||
import { updateMyMarketingPreference } from '@/app/actions/marketing-preference'
|
||||
import { motion } from 'motion/react'
|
||||
import { openCookiePreferences } from '@/lib/consent/cookie-consent'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
@@ -21,10 +22,14 @@ interface GeneralSettingsClientProps {
|
||||
desktopNotifications: boolean
|
||||
autoSave: boolean
|
||||
}
|
||||
initialMarketing?: {
|
||||
optedIn: boolean
|
||||
blocked: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClientProps) {
|
||||
const { t, setLanguage: setContextLanguage } = useLanguage()
|
||||
export function GeneralSettingsClient({ initialSettings, initialMarketing }: GeneralSettingsClientProps) {
|
||||
const { t, language: uiLanguage, setLanguage: setContextLanguage } = useLanguage()
|
||||
const { hasAiConsent, revokeConsent, requestAiConsent } = useAiConsent()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
@@ -45,6 +50,9 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
||||
const [emailNotifications, setEmailNotifications] = useState(initialSettings.emailNotifications ?? false)
|
||||
const [desktopNotifications, setDesktopNotifications] = useState(initialSettings.desktopNotifications ?? false)
|
||||
const [autoSave, setAutoSave] = useState(initialSettings.autoSave ?? true)
|
||||
const [marketingOptIn, setMarketingOptIn] = useState(initialMarketing?.optedIn === true)
|
||||
const [marketingBlocked] = useState(initialMarketing?.blocked === true)
|
||||
const [marketingSaving, setMarketingSaving] = useState(false)
|
||||
|
||||
const handleLanguageChange = async (value: string) => {
|
||||
setLanguage(value)
|
||||
@@ -80,6 +88,20 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
||||
toast.success(t('settings.settingsSaved'))
|
||||
}
|
||||
|
||||
const handleMarketingChange = async (enabled: boolean) => {
|
||||
if (marketingBlocked) return
|
||||
setMarketingSaving(true)
|
||||
setMarketingOptIn(enabled)
|
||||
const result = await updateMyMarketingPreference(enabled, uiLanguage)
|
||||
setMarketingSaving(false)
|
||||
if (!result.ok) {
|
||||
setMarketingOptIn(!enabled)
|
||||
toast.error(t(result.error === 'blocked' ? 'settings.marketingBlocked' : 'settings.settingsSaveFailed'))
|
||||
return
|
||||
}
|
||||
toast.success(t('settings.settingsSaved'))
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
@@ -222,6 +244,38 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-xl p-8 space-y-6 md:col-span-2">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-concrete border border-border">
|
||||
<Mail size={18} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-base font-bold text-ink">{t('settings.marketingTitle')}</h4>
|
||||
<p className="text-[11px] text-concrete max-w-2xl leading-relaxed">
|
||||
{t('settings.marketingDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className={cn('relative inline-flex items-center shrink-0', marketingBlocked || marketingSaving ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={marketingOptIn}
|
||||
disabled={marketingBlocked || marketingSaving}
|
||||
onChange={(e) => handleMarketingChange(e.target.checked)}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-brand-accent" />
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-[12px] leading-relaxed text-ink/80">
|
||||
{t('settings.marketingLegal')}
|
||||
</p>
|
||||
{marketingBlocked && (
|
||||
<p className="text-[12px] text-rose-600">{t('settings.marketingBlocked')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={aiConsentSectionRef}
|
||||
className={cn(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { auth } from '@/auth'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getAISettings } from '@/app/actions/ai-settings'
|
||||
import { getMyMarketingPreference } from '@/app/actions/marketing-preference'
|
||||
import { GeneralSettingsClient } from './general-settings-client'
|
||||
|
||||
export default async function GeneralSettingsPage() {
|
||||
@@ -15,10 +16,15 @@ export default async function GeneralSettingsPage() {
|
||||
desktopNotifications,
|
||||
autoSave,
|
||||
} = await getAISettings()
|
||||
const marketing = await getMyMarketingPreference()
|
||||
|
||||
return (
|
||||
<GeneralSettingsClient
|
||||
initialSettings={{ preferredLanguage, emailNotifications, desktopNotifications, autoSave }}
|
||||
initialMarketing={{
|
||||
optedIn: marketing?.optedIn === true,
|
||||
blocked: marketing?.blocked === true,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
29
memento-note/app/actions/marketing-preference.ts
Normal file
29
memento-note/app/actions/marketing-preference.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
'use server'
|
||||
|
||||
import { auth } from '@/auth'
|
||||
import { marketingConsentText } from '@/lib/marketing/consent-copy'
|
||||
import { getMarketingPreferenceView, setMarketingOptIn } from '@/lib/marketing/preference'
|
||||
import { marketingRequestMeta } from '@/lib/marketing/request-meta'
|
||||
|
||||
export async function getMyMarketingPreference() {
|
||||
const session = await auth()
|
||||
const userId = (session?.user as { id?: string } | undefined)?.id
|
||||
if (!userId) return null
|
||||
return getMarketingPreferenceView(userId)
|
||||
}
|
||||
|
||||
export async function updateMyMarketingPreference(optedIn: boolean, language?: string) {
|
||||
const session = await auth()
|
||||
const userId = (session?.user as { id?: string } | undefined)?.id
|
||||
if (!userId) return { ok: false as const, error: 'auth' as const }
|
||||
const meta = await marketingRequestMeta()
|
||||
const result = await setMarketingOptIn({
|
||||
userId,
|
||||
optedIn,
|
||||
source: 'settings',
|
||||
language: language || null,
|
||||
meta,
|
||||
})
|
||||
if (!result.ok) return { ok: false as const, error: result.error }
|
||||
return { ok: true as const, legalText: optedIn ? marketingConsentText(language) : null }
|
||||
}
|
||||
30
memento-note/app/actions/marketing-unsubscribe.ts
Normal file
30
memento-note/app/actions/marketing-unsubscribe.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
'use server'
|
||||
|
||||
import { unsubscribeByUserId } from '@/lib/marketing/preference'
|
||||
import { marketingRequestMeta } from '@/lib/marketing/request-meta'
|
||||
import { verifyUnsubscribeToken } from '@/lib/marketing/unsubscribe-token'
|
||||
|
||||
export async function previewUnsubscribeToken(token: string | null): Promise<{
|
||||
valid: boolean
|
||||
}> {
|
||||
if (!token) return { valid: false }
|
||||
const payload = verifyUnsubscribeToken(token)
|
||||
return { valid: Boolean(payload) }
|
||||
}
|
||||
|
||||
export async function confirmMarketingUnsubscribe(token: string): Promise<{
|
||||
ok: boolean
|
||||
already?: boolean
|
||||
invalid?: boolean
|
||||
}> {
|
||||
const payload = verifyUnsubscribeToken(token)
|
||||
if (!payload) return { ok: false, invalid: true }
|
||||
const meta = await marketingRequestMeta()
|
||||
const result = await unsubscribeByUserId({
|
||||
userId: payload.userId,
|
||||
source: 'unsubscribe',
|
||||
meta,
|
||||
})
|
||||
if (!result.ok) return { ok: false, invalid: true }
|
||||
return { ok: true, already: result.already }
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export async function register(prevState: string | undefined, formData: FormData
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
const role = isAdmin ? 'ADMIN' : 'USER';
|
||||
|
||||
await prisma.user.create({
|
||||
const created = await prisma.user.create({
|
||||
data: {
|
||||
email: normalizedEmail,
|
||||
password: hashedPassword,
|
||||
@@ -61,6 +61,25 @@ export async function register(prevState: string | undefined, formData: FormData
|
||||
},
|
||||
});
|
||||
|
||||
if (formData.get('marketingConsent') === '1') {
|
||||
try {
|
||||
const { setMarketingOptIn } = await import('@/lib/marketing/preference')
|
||||
const { marketingRequestMeta } = await import('@/lib/marketing/request-meta')
|
||||
const meta = await marketingRequestMeta()
|
||||
await setMarketingOptIn({
|
||||
userId: created.id,
|
||||
optedIn: true,
|
||||
source: 'register',
|
||||
language: typeof formData.get('marketingLanguage') === 'string'
|
||||
? String(formData.get('marketingLanguage'))
|
||||
: null,
|
||||
meta,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[register] marketing consent save failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
const mailResult = await sendVerificationEmail({
|
||||
email: normalizedEmail,
|
||||
|
||||
53
memento-note/app/api/marketing/unsubscribe/route.ts
Normal file
53
memento-note/app/api/marketing/unsubscribe/route.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { unsubscribeByUserId } from '@/lib/marketing/preference'
|
||||
import { publicAppOrigin, verifyUnsubscribeToken } from '@/lib/marketing/unsubscribe-token'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
function tokenFromRequest(request: NextRequest, bodyToken?: string | null): string | null {
|
||||
return (
|
||||
request.nextUrl.searchParams.get('token') ||
|
||||
bodyToken ||
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
async function applyUnsubscribe(request: NextRequest, token: string | null) {
|
||||
const payload = token ? verifyUnsubscribeToken(token) : null
|
||||
if (!payload) {
|
||||
return NextResponse.json({ ok: false }, { status: 400 })
|
||||
}
|
||||
const forwarded = request.headers.get('x-forwarded-for')
|
||||
const ip = forwarded?.split(',')[0]?.trim() || request.headers.get('x-real-ip')
|
||||
await unsubscribeByUserId({
|
||||
userId: payload.userId,
|
||||
source: 'unsubscribe-one-click',
|
||||
meta: { ip, userAgent: request.headers.get('user-agent') },
|
||||
})
|
||||
return new NextResponse('OK', { status: 200 })
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
let bodyToken: string | null = null
|
||||
const contentType = request.headers.get('content-type') || ''
|
||||
try {
|
||||
if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
const form = await request.formData()
|
||||
bodyToken = String(form.get('token') || '')
|
||||
} else if (contentType.includes('application/json')) {
|
||||
const json = await request.json().catch(() => null)
|
||||
bodyToken = json?.token ? String(json.token) : null
|
||||
}
|
||||
} catch {
|
||||
bodyToken = null
|
||||
}
|
||||
return applyUnsubscribe(request, tokenFromRequest(request, bodyToken))
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const token = request.nextUrl.searchParams.get('token') || ''
|
||||
const origin = publicAppOrigin() || request.nextUrl.origin
|
||||
const url = new URL('/unsubscribe', origin)
|
||||
if (token) url.searchParams.set('token', token)
|
||||
return NextResponse.redirect(url, 303)
|
||||
}
|
||||
@@ -36,7 +36,9 @@ export const authConfig = {
|
||||
nextUrl.pathname === '/forgot-password' ||
|
||||
nextUrl.pathname === '/check-email' ||
|
||||
nextUrl.pathname === '/verify-email' ||
|
||||
nextUrl.pathname.startsWith('/reset-password');
|
||||
nextUrl.pathname.startsWith('/reset-password') ||
|
||||
nextUrl.pathname === '/unsubscribe' ||
|
||||
nextUrl.pathname.startsWith('/unsubscribe/');
|
||||
|
||||
if (isAdminPage) {
|
||||
return isLoggedIn && isAdmin;
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
LogOut,
|
||||
Brain,
|
||||
CreditCard,
|
||||
Mail,
|
||||
BarChart3,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
@@ -48,6 +50,16 @@ const ADMIN_NAV_ITEMS = [
|
||||
href: '/admin/billing',
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
titleKey: 'admin.sidebar.marketing',
|
||||
href: '/admin/marketing',
|
||||
icon: Mail,
|
||||
},
|
||||
{
|
||||
titleKey: 'admin.sidebar.stats',
|
||||
href: '/admin/stats',
|
||||
icon: BarChart3,
|
||||
},
|
||||
{
|
||||
titleKey: 'admin.sidebar.published',
|
||||
href: '/admin/published',
|
||||
|
||||
@@ -43,7 +43,7 @@ function AuthDivider({ label }: { label: string }) {
|
||||
|
||||
export function RegisterForm({ googleAuthEnabled = false }: { googleAuthEnabled?: boolean }) {
|
||||
const [errorMessage, dispatch] = useActionState(register, undefined);
|
||||
const { t } = useLanguage();
|
||||
const { t, language } = useLanguage();
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl">
|
||||
@@ -147,6 +147,19 @@ export function RegisterForm({ googleAuthEnabled = false }: { googleAuthEnabled?
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-3 px-1 pt-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="marketingConsent"
|
||||
value="1"
|
||||
className="mt-1 size-4 rounded border-[var(--border)] accent-[var(--color-brand-accent)]"
|
||||
/>
|
||||
<span className="text-left text-xs leading-relaxed text-[var(--muted-foreground)]">
|
||||
{t('auth.marketingConsent')}
|
||||
</span>
|
||||
</label>
|
||||
<input type="hidden" name="marketingLanguage" value={language} />
|
||||
|
||||
<RegisterButton />
|
||||
|
||||
<div
|
||||
|
||||
24
memento-note/lib/marketing/consent-copy.ts
Normal file
24
memento-note/lib/marketing/consent-copy.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export const MARKETING_CONSENT_VERSION = 'memento-announcements-2026-09-v1'
|
||||
|
||||
const COPY: Record<string, string> = {
|
||||
fr: 'J’accepte de recevoir, de temps en temps, des actualités et des offres de Memento. Ce n’est pas obligatoire. Les messages indispensables (compte, facture, mot de passe) continueront.',
|
||||
en: 'I agree to receive occasional Memento news and offers. This is optional. Essential messages (account, billing, password) will continue.',
|
||||
de: 'Ich stimme zu, gelegentlich Neuigkeiten und Angebote von Memento zu erhalten. Das ist freiwillig. Unerlässliche Nachrichten (Konto, Rechnung, Passwort) gehen weiter.',
|
||||
es: 'Acepto recibir de vez en cuando novedades y ofertas de Memento. No es obligatorio. Los mensajes imprescindibles (cuenta, factura, contraseña) seguirán llegando.',
|
||||
it: 'Accetto di ricevere di tanto in tanto novità e offerte di Memento. Non è obbligatorio. I messaggi indispensabili (account, fattura, password) continueranno.',
|
||||
pt: 'Aceito receber de vez em quando novidades e ofertas da Memento. Não é obrigatório. As mensagens indispensáveis (conta, fatura, palavra-passe) continuam.',
|
||||
nl: 'Ik ga akkoord om af en toe nieuws en aanbiedingen van Memento te ontvangen. Dit is niet verplicht. Essentiële berichten (account, factuur, wachtwoord) blijven komen.',
|
||||
pl: 'Wyrażam zgodę na okazjonalne wiadomości i oferty Memento. To nie jest obowiązkowe. Niezbędne wiadomości (konto, faktura, hasło) będą nadal wysyłane.',
|
||||
ru: 'Я соглашаюсь иногда получать новости и предложения Memento. Это необязательно. Обязательные письма (аккаунт, счёт, пароль) продолжат приходить.',
|
||||
zh: '我同意偶尔接收 Memento 的近况和优惠。这不是必须的。必要邮件(账户、账单、密码)仍会发送。',
|
||||
ja: 'Memento の近況や特典をときどき受け取ることへ同意します。必須ではありません。必要な連絡(アカウント、請求、パスワード)は続きます。',
|
||||
ko: 'Memento 소식과 혜택을 가끔 받는 데 동의합니다. 필수는 아닙니다. 꼭 필요한 메일(계정, 청구, 비밀번호)은 계속 옵니다.',
|
||||
ar: 'أوافق على تلقّي أخبار وعروض ميمنتو من حين لآخر. هذا اختياري. الرسائل الضرورية (الحساب، الفاتورة، كلمة المرور) ستستمر.',
|
||||
fa: 'میپذیرم گاهی اخبار و پیشنهادهای Memento را دریافت کنم. اجباری نیست. پیامهای ضروری (حساب، صورتحساب، گذرواژه) ادامه دارند.',
|
||||
hi: 'मैं कभी-कभी Memento की खबरें और प्रस्ताव पाने के लिए सहमत हूँ। यह ज़रूरी नहीं है। ज़रूरी पत्र (खाता, बिल, पासवर्ड) आते रहेंगे।',
|
||||
}
|
||||
|
||||
export function marketingConsentText(language?: string | null): string {
|
||||
const lang = (language || 'en').toLowerCase()
|
||||
return COPY[lang] || COPY.en
|
||||
}
|
||||
3
memento-note/lib/marketing/normalize-email.ts
Normal file
3
memento-note/lib/marketing/normalize-email.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function normalizeMarketingEmail(email: string): string {
|
||||
return email.trim().toLowerCase()
|
||||
}
|
||||
205
memento-note/lib/marketing/preference.ts
Normal file
205
memento-note/lib/marketing/preference.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import { MARKETING_CONSENT_VERSION, marketingConsentText } from '@/lib/marketing/consent-copy'
|
||||
import { normalizeMarketingEmail } from '@/lib/marketing/normalize-email'
|
||||
|
||||
export const MARKETING_BLOCKING_REASONS = ['COMPLAINT', 'HARD_BOUNCE', 'INVALID'] as const
|
||||
|
||||
export type MarketingSource =
|
||||
| 'settings'
|
||||
| 'register'
|
||||
| 'unsubscribe'
|
||||
| 'unsubscribe-one-click'
|
||||
| 'complaint'
|
||||
| 'bounce'
|
||||
|
||||
export type MarketingPreferenceView = {
|
||||
optedIn: boolean
|
||||
emailNormalized: string
|
||||
consentVersion: string | null
|
||||
updatedAt: Date | null
|
||||
blocked: boolean
|
||||
blockReason: string | null
|
||||
}
|
||||
|
||||
type RequestMeta = {
|
||||
ip?: string | null
|
||||
userAgent?: string | null
|
||||
}
|
||||
|
||||
export async function getMarketingPreferenceView(userId: string): Promise<MarketingPreferenceView> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
email: true,
|
||||
marketingPreference: true,
|
||||
},
|
||||
})
|
||||
const emailNormalized = normalizeMarketingEmail(user?.email || '')
|
||||
const suppression = emailNormalized
|
||||
? await prisma.emailSuppression.findUnique({ where: { emailNormalized } })
|
||||
: null
|
||||
const blocked = Boolean(
|
||||
suppression && MARKETING_BLOCKING_REASONS.includes(suppression.reason as (typeof MARKETING_BLOCKING_REASONS)[number]),
|
||||
)
|
||||
return {
|
||||
optedIn: user?.marketingPreference?.optedIn === true && !suppression,
|
||||
emailNormalized,
|
||||
consentVersion: user?.marketingPreference?.consentVersion ?? null,
|
||||
updatedAt: user?.marketingPreference?.updatedAt ?? null,
|
||||
blocked,
|
||||
blockReason: blocked ? suppression?.reason ?? null : null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function canReceiveMarketing(opts: {
|
||||
userId?: string | null
|
||||
email: string
|
||||
requireVerified?: boolean
|
||||
}): Promise<{ allowed: boolean; reason: string }> {
|
||||
const emailNormalized = normalizeMarketingEmail(opts.email)
|
||||
if (!emailNormalized) return { allowed: false, reason: 'invalid_email' }
|
||||
|
||||
const suppression = await prisma.emailSuppression.findUnique({ where: { emailNormalized } })
|
||||
if (suppression) return { allowed: false, reason: `suppressed:${suppression.reason}` }
|
||||
|
||||
const user = opts.userId
|
||||
? await prisma.user.findUnique({
|
||||
where: { id: opts.userId },
|
||||
select: {
|
||||
emailVerified: true,
|
||||
marketingPreference: { select: { optedIn: true, emailNormalized: true } },
|
||||
},
|
||||
})
|
||||
: await prisma.user.findUnique({
|
||||
where: { email: emailNormalized },
|
||||
select: {
|
||||
emailVerified: true,
|
||||
marketingPreference: { select: { optedIn: true, emailNormalized: true } },
|
||||
},
|
||||
})
|
||||
|
||||
if (!user) return { allowed: false, reason: 'no_account' }
|
||||
if (opts.requireVerified !== false && !user.emailVerified) {
|
||||
return { allowed: false, reason: 'unverified' }
|
||||
}
|
||||
if (!user.marketingPreference?.optedIn) return { allowed: false, reason: 'not_opted_in' }
|
||||
return { allowed: true, reason: 'ok' }
|
||||
}
|
||||
|
||||
export async function setMarketingOptIn(opts: {
|
||||
userId: string
|
||||
optedIn: boolean
|
||||
source: MarketingSource
|
||||
language?: string | null
|
||||
meta?: RequestMeta
|
||||
}): Promise<{ ok: true } | { ok: false; error: 'blocked' | 'missing_user' }> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: opts.userId },
|
||||
select: { id: true, email: true },
|
||||
})
|
||||
if (!user) return { ok: false, error: 'missing_user' }
|
||||
|
||||
const emailNormalized = normalizeMarketingEmail(user.email)
|
||||
const existingSuppression = await prisma.emailSuppression.findUnique({
|
||||
where: { emailNormalized },
|
||||
})
|
||||
if (
|
||||
opts.optedIn &&
|
||||
existingSuppression &&
|
||||
MARKETING_BLOCKING_REASONS.includes(existingSuppression.reason as (typeof MARKETING_BLOCKING_REASONS)[number])
|
||||
) {
|
||||
return { ok: false, error: 'blocked' }
|
||||
}
|
||||
|
||||
const consentText = opts.optedIn ? marketingConsentText(opts.language) : null
|
||||
const consentVersion = opts.optedIn ? MARKETING_CONSENT_VERSION : null
|
||||
|
||||
const preference = await prisma.marketingPreference.upsert({
|
||||
where: { userId: user.id },
|
||||
create: {
|
||||
userId: user.id,
|
||||
emailNormalized,
|
||||
optedIn: opts.optedIn,
|
||||
preferredLanguage: opts.language || null,
|
||||
consentVersion,
|
||||
consentText,
|
||||
source: opts.source,
|
||||
},
|
||||
update: {
|
||||
emailNormalized,
|
||||
optedIn: opts.optedIn,
|
||||
preferredLanguage: opts.language || undefined,
|
||||
consentVersion: opts.optedIn ? consentVersion : undefined,
|
||||
consentText: opts.optedIn ? consentText : undefined,
|
||||
source: opts.source,
|
||||
},
|
||||
})
|
||||
|
||||
await prisma.marketingPreferenceEvent.create({
|
||||
data: {
|
||||
preferenceId: preference.id,
|
||||
userId: user.id,
|
||||
emailNormalized,
|
||||
optedIn: opts.optedIn,
|
||||
source: opts.source,
|
||||
consentVersion,
|
||||
consentText,
|
||||
ip: opts.meta?.ip || null,
|
||||
userAgent: opts.meta?.userAgent || null,
|
||||
},
|
||||
})
|
||||
|
||||
if (opts.optedIn) {
|
||||
if (existingSuppression?.reason === 'UNSUBSCRIBE') {
|
||||
await prisma.emailSuppression.delete({ where: { emailNormalized } })
|
||||
}
|
||||
} else {
|
||||
await prisma.emailSuppression.upsert({
|
||||
where: { emailNormalized },
|
||||
create: {
|
||||
emailNormalized,
|
||||
reason: 'UNSUBSCRIBE',
|
||||
source: opts.source,
|
||||
userId: user.id,
|
||||
},
|
||||
update: {
|
||||
reason: existingSuppression && MARKETING_BLOCKING_REASONS.includes(existingSuppression.reason as (typeof MARKETING_BLOCKING_REASONS)[number])
|
||||
? existingSuppression.reason
|
||||
: 'UNSUBSCRIBE',
|
||||
source: opts.source,
|
||||
userId: user.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
action: opts.optedIn ? 'marketing.opt_in' : 'marketing.opt_out',
|
||||
resource: 'MarketingPreference',
|
||||
metadata: { source: opts.source, consentVersion },
|
||||
ip: opts.meta?.ip || null,
|
||||
userAgent: opts.meta?.userAgent || null,
|
||||
},
|
||||
}).catch(() => {})
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
export async function unsubscribeByUserId(opts: {
|
||||
userId: string
|
||||
source: MarketingSource
|
||||
meta?: RequestMeta
|
||||
}): Promise<{ ok: true; already: boolean } | { ok: false }> {
|
||||
const view = await getMarketingPreferenceView(opts.userId)
|
||||
if (!view.emailNormalized) return { ok: false }
|
||||
const already = !view.optedIn
|
||||
const result = await setMarketingOptIn({
|
||||
userId: opts.userId,
|
||||
optedIn: false,
|
||||
source: opts.source,
|
||||
meta: opts.meta,
|
||||
})
|
||||
if (!result.ok) return { ok: false }
|
||||
return { ok: true, already }
|
||||
}
|
||||
11
memento-note/lib/marketing/request-meta.ts
Normal file
11
memento-note/lib/marketing/request-meta.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { headers } from 'next/headers'
|
||||
|
||||
export async function marketingRequestMeta(): Promise<{ ip: string | null; userAgent: string | null }> {
|
||||
const h = await headers()
|
||||
const forwarded = h.get('x-forwarded-for')
|
||||
const ip = forwarded?.split(',')[0]?.trim() || h.get('x-real-ip') || null
|
||||
return {
|
||||
ip,
|
||||
userAgent: h.get('user-agent'),
|
||||
}
|
||||
}
|
||||
71
memento-note/lib/marketing/unsubscribe-token.ts
Normal file
71
memento-note/lib/marketing/unsubscribe-token.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { createHmac, timingSafeEqual } from 'crypto'
|
||||
|
||||
const TOKEN_TTL_MS = 10 * 365 * 24 * 60 * 60 * 1000
|
||||
|
||||
function getSecret(): string {
|
||||
const secret = process.env.NEXTAUTH_SECRET
|
||||
if (!secret) {
|
||||
throw new Error('NEXTAUTH_SECRET is required for unsubscribe tokens')
|
||||
}
|
||||
return secret
|
||||
}
|
||||
|
||||
export type UnsubscribeTokenPayload = {
|
||||
userId: string
|
||||
issuedAt: number
|
||||
}
|
||||
|
||||
export function createUnsubscribeToken(userId: string): string {
|
||||
const issuedAt = Date.now()
|
||||
const inner = Buffer.from(`${userId}.${issuedAt}`, 'utf8').toString('base64url')
|
||||
const sig = createHmac('sha256', getSecret()).update(inner).digest('base64url')
|
||||
return `${inner}.${sig}`
|
||||
}
|
||||
|
||||
export function verifyUnsubscribeToken(token: string): UnsubscribeTokenPayload | null {
|
||||
try {
|
||||
const trimmed = token.trim()
|
||||
const lastDot = trimmed.lastIndexOf('.')
|
||||
if (lastDot <= 0) return null
|
||||
const sig = trimmed.slice(lastDot + 1)
|
||||
const inner = trimmed.slice(0, lastDot)
|
||||
const expected = createHmac('sha256', getSecret()).update(inner).digest('base64url')
|
||||
const sigBuf = Buffer.from(sig)
|
||||
const expectedBuf = Buffer.from(expected)
|
||||
if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) {
|
||||
return null
|
||||
}
|
||||
const decoded = Buffer.from(inner, 'base64url').toString('utf8')
|
||||
const firstDot = decoded.indexOf('.')
|
||||
if (firstDot <= 0) return null
|
||||
const userId = decoded.slice(0, firstDot)
|
||||
const issuedAt = Number(decoded.slice(firstDot + 1))
|
||||
if (!userId || !Number.isFinite(issuedAt)) return null
|
||||
if (Date.now() - issuedAt > TOKEN_TTL_MS) return null
|
||||
return { userId, issuedAt }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function publicAppOrigin(): string {
|
||||
return (process.env.NEXTAUTH_URL || '').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
export function unsubscribePageUrl(token: string): string {
|
||||
return `${publicAppOrigin()}/unsubscribe?token=${encodeURIComponent(token)}`
|
||||
}
|
||||
|
||||
export function unsubscribeOneClickUrl(token: string): string {
|
||||
return `${publicAppOrigin()}/api/marketing/unsubscribe?token=${encodeURIComponent(token)}`
|
||||
}
|
||||
|
||||
/** Headers for future campaign messages (RFC 8058). */
|
||||
export function listUnsubscribeHeaders(token: string): Record<string, string> {
|
||||
const page = unsubscribePageUrl(token)
|
||||
const oneClick = unsubscribeOneClickUrl(token)
|
||||
return {
|
||||
'List-Unsubscribe': `<${oneClick}>, <${page}>`,
|
||||
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,16 @@
|
||||
{
|
||||
"unsubscribe": {
|
||||
"title": "إيقاف الأخبار والعروض؟",
|
||||
"body": "هذا يوقف الإعلانات والعروض فقط. الحساب والفاتورة وكلمة المرور بلا تغيير. أكّد بالزر — فتح هذه الصفحة لا يكفي.",
|
||||
"confirm": "إيقاف الإعلانات",
|
||||
"keep": "متابعة التلقّي",
|
||||
"successTitle": "تم إلغاء الاشتراك",
|
||||
"successBody": "لن تصلك أخبار وعروض ميمنتو على هذا العنوان. رسائل الخدمة الضرورية قد تستمر.",
|
||||
"invalidTitle": "هذا الرابط غير صالح",
|
||||
"invalidBody": "الرابط ناقص أو لم يعد صالحًا. يمكنك أيضًا إيقاف الإعلانات من الإعدادات.",
|
||||
"openSettings": "فتح الإعدادات",
|
||||
"backToSignIn": "تسجيل الدخول"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "تسجيل الدخول",
|
||||
"signUp": "إنشاء حساب",
|
||||
@@ -14,6 +26,7 @@
|
||||
"forgotPassword": "هل نسيت كلمة المرور؟",
|
||||
"noAccount": "ليس لديك حساب؟",
|
||||
"hasAccount": "لديك حساب بالفعل؟",
|
||||
"marketingConsent": "أوافق على تلقّي أخبار وعروض ميمنتو من حين لآخر (اختياري). الرسائل الضرورية عن حسابي ستستمر.",
|
||||
"signInToAccount": "سجل الدخول إلى حسابك",
|
||||
"createAccount": "أنشئ حسابك",
|
||||
"rememberMe": "تذكرني",
|
||||
@@ -1031,6 +1044,11 @@
|
||||
"about": "حول",
|
||||
"version": "الإصدار",
|
||||
"settingsSaved": "تم حفظ الإعدادات",
|
||||
"marketingTitle": "الأخبار والعروض",
|
||||
"marketingDescription": "تلقّي أخبار وعروض ميمنتو من حين لآخر. هذا اختياري ومنفصل عن رسائل الحساب والفاتورة وكلمة المرور.",
|
||||
"marketingLegal": "يمكنك الإيقاف في أي وقت. حملة سُلِّمت بالفعل إلى خدمة الإرسال لا يمكن استرجاعها.",
|
||||
"marketingBlocked": "لا يمكن لهذا العنوان تلقّي الإعلانات (شكوى أو فشل تسليم نهائي).",
|
||||
"settingsSaveFailed": "تعذّر حفظ هذا الاختيار.",
|
||||
"cardSizeMode": "حجم الملاحظة",
|
||||
"cardSizeModeDescription": "اختر بين أحجام متغيرة أو حجم موحد",
|
||||
"selectCardSizeMode": "اختر وضع العرض",
|
||||
@@ -1527,7 +1545,42 @@
|
||||
"agents": "الوكلاء",
|
||||
"settings": "الإعدادات",
|
||||
"billing": "الفوترة والحصص",
|
||||
"published": "الصفحات المنشورة"
|
||||
"published": "الصفحات المنشورة",
|
||||
"marketing": "التسويق",
|
||||
"stats": "الإحصاءات"
|
||||
},
|
||||
"marketing": {
|
||||
"title": "التسويق",
|
||||
"description": "إعداد الحملات واختيار المستلمين وإرفاق عرض. الإرسال غير مفعّل بعد.",
|
||||
"emptyTitle": "لا حملات بعد",
|
||||
"emptyBody": "عندما توجد حملات ستظهر في هذا الجدول مع الجمهور والعرض والحالة. هذه الصفحة لا ترسل أي بريد.",
|
||||
"createCampaign": "إنشاء حملة",
|
||||
"createDisabledHint": "إرسال الحملات غير مفعّل في هذا الإصدار.",
|
||||
"columns": {
|
||||
"name": "الاسم الداخلي",
|
||||
"goal": "الغرض",
|
||||
"audience": "المستلمون",
|
||||
"language": "اللغة",
|
||||
"offer": "العرض",
|
||||
"status": "الحالة",
|
||||
"scheduledAt": "التاريخ المخطط",
|
||||
"accepted": "قبلتها خدمة الإرسال",
|
||||
"delivered": "تم التسليم",
|
||||
"paidConversions": "تحويلات مدفوعة"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"title": "الإحصاءات",
|
||||
"description": "الإيرادات والاشتراكات ونشاط المنتج ونتائج الحملات. تظهر الأرقام فقط من مصدر موثوق.",
|
||||
"unavailable": "غير متاح بعد",
|
||||
"unavailableBody": "هذا القسم غير مربوط بقياس بعد. لا يظهر صفرًا لأن الصفر يعني أن شيئًا لم يحدث.",
|
||||
"lastSyncUnknown": "آخر تحديث: غير معروف",
|
||||
"sectionRevenue": "الإيرادات",
|
||||
"sectionSubscriptions": "الاشتراكات",
|
||||
"sectionUsage": "الاستخدام",
|
||||
"sectionCampaigns": "الحملات",
|
||||
"periodLabel": "الفترة",
|
||||
"timezoneNote": "ستتبع التواريخ المنطقة الزمنية للمشرف عندما تتوفر القياسات."
|
||||
},
|
||||
"metrics": {
|
||||
"vsLastPeriod": "vs الفترة السابقة"
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
{
|
||||
"unsubscribe": {
|
||||
"title": "Neuigkeiten und Angebote beenden?",
|
||||
"body": "Das beendet nur Ankündigungen und Angebote. Konto, Rechnung und Passwort bleiben. Bestätigen Sie mit der Schaltfläche — die Seite zu öffnen reicht nicht.",
|
||||
"confirm": "Ankündigungen beenden",
|
||||
"keep": "Weiterhin erhalten",
|
||||
"successTitle": "Abgemeldet",
|
||||
"successBody": "Sie erhalten an dieser Adresse keine Memento-Neuigkeiten und Angebote mehr. Unerlässliche Dienstnachrichten können weiterkommen.",
|
||||
"invalidTitle": "Dieser Link gilt nicht",
|
||||
"invalidBody": "Der Link fehlt oder ist veraltet. Sie können Ankündigungen auch in den Einstellungen beenden.",
|
||||
"openSettings": "Einstellungen öffnen",
|
||||
"backToSignIn": "Anmelden"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Anmelden",
|
||||
"signUp": "Registrieren",
|
||||
@@ -14,6 +26,7 @@
|
||||
"forgotPassword": "Passwort vergessen?",
|
||||
"noAccount": "Haben Sie kein Konto?",
|
||||
"hasAccount": "Haben Sie bereits ein Konto?",
|
||||
"marketingConsent": "Ich stimme zu, gelegentlich Neuigkeiten und Angebote von Memento zu erhalten (optional). Unerlässliche Kontonachrichten gehen weiter.",
|
||||
"signInToAccount": "Melden Sie sich in Ihrem Konto an",
|
||||
"createAccount": "Erstellen Sie Ihr Konto",
|
||||
"rememberMe": "Angemeldet bleiben",
|
||||
@@ -1031,6 +1044,11 @@
|
||||
"about": "Über",
|
||||
"version": "Version",
|
||||
"settingsSaved": "Einstellungen gespeichert",
|
||||
"marketingTitle": "Neuigkeiten und Angebote",
|
||||
"marketingDescription": "Gelegentlich Neuigkeiten und Angebote von Memento. Freiwillig und getrennt von Konto-, Rechnungs- und Passwortnachrichten.",
|
||||
"marketingLegal": "Sie können jederzeit aufhören. Eine bereits übergebene Kampagne lässt sich nicht zurückholen.",
|
||||
"marketingBlocked": "Diese Adresse kann keine Ankündigungen empfangen (Beschwerde oder dauerhafter Zustellfehler).",
|
||||
"settingsSaveFailed": "Diese Wahl konnte nicht gespeichert werden.",
|
||||
"cardSizeMode": "Notizgröße",
|
||||
"cardSizeModeDescription": "Wählen Sie zwischen variablen oder einheitlichen Größen",
|
||||
"selectCardSizeMode": "Anzeigemodus auswählen",
|
||||
@@ -1527,7 +1545,42 @@
|
||||
"agents": "Agenten",
|
||||
"settings": "Einstellungen",
|
||||
"billing": "Abrechnung & Kontingente",
|
||||
"published": "Veröffentlichte Seiten"
|
||||
"published": "Veröffentlichte Seiten",
|
||||
"marketing": "Marketing",
|
||||
"stats": "Statistiken"
|
||||
},
|
||||
"marketing": {
|
||||
"title": "Marketing",
|
||||
"description": "Kampagnen vorbereiten, Empfänger wählen und ein Angebot anhängen. Der Versand ist noch nicht aktiv.",
|
||||
"emptyTitle": "Noch keine Kampagnen",
|
||||
"emptyBody": "Sobald Kampagnen existieren, erscheinen sie in dieser Tabelle mit Empfängern, Angebot und Status. Diese Seite versendet keine E-Mails.",
|
||||
"createCampaign": "Kampagne erstellen",
|
||||
"createDisabledHint": "Der Kampagnenversand ist in dieser Version nicht aktiv.",
|
||||
"columns": {
|
||||
"name": "Interner Name",
|
||||
"goal": "Zweck",
|
||||
"audience": "Empfänger",
|
||||
"language": "Sprache",
|
||||
"offer": "Angebot",
|
||||
"status": "Status",
|
||||
"scheduledAt": "Geplantes Datum",
|
||||
"accepted": "Vom Versanddienst angenommen",
|
||||
"delivered": "Zugestellt",
|
||||
"paidConversions": "Bezahlte Abschlüsse"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"title": "Statistiken",
|
||||
"description": "Umsatz, Abonnements, Produktnutzung und Kampagnenergebnisse. Zahlen erscheinen nur aus einer zuverlässigen Quelle.",
|
||||
"unavailable": "Noch nicht verfügbar",
|
||||
"unavailableBody": "Dieser Bereich ist noch nicht mit einer Messung verbunden. Es wird keine Null angezeigt, weil Null „nichts ist passiert“ bedeuten würde.",
|
||||
"lastSyncUnknown": "Letzte Aktualisierung: unbekannt",
|
||||
"sectionRevenue": "Umsatz",
|
||||
"sectionSubscriptions": "Abonnements",
|
||||
"sectionUsage": "Nutzung",
|
||||
"sectionCampaigns": "Kampagnen",
|
||||
"periodLabel": "Zeitraum",
|
||||
"timezoneNote": "Daten folgen der Zeitzone der Verwaltung, sobald Messungen vorliegen."
|
||||
},
|
||||
"metrics": {
|
||||
"vsLastPeriod": "vs vorheriger Zeitraum"
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
{
|
||||
"unsubscribe": {
|
||||
"title": "Stop news and offers?",
|
||||
"body": "This only stops announcements and offers. Account, billing, and password messages are unchanged. Confirm with the button — opening this page is not enough.",
|
||||
"confirm": "Stop announcements",
|
||||
"keep": "Keep receiving them",
|
||||
"successTitle": "You are unsubscribed",
|
||||
"successBody": "You will no longer receive Memento news and offers at this address. Essential service messages may still arrive.",
|
||||
"invalidTitle": "This link is not valid",
|
||||
"invalidBody": "The link is missing or outdated. You can also stop announcements in Settings.",
|
||||
"openSettings": "Open settings",
|
||||
"backToSignIn": "Sign in"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Sign In",
|
||||
"signUp": "Sign Up",
|
||||
@@ -14,6 +26,7 @@
|
||||
"forgotPassword": "Forgot password?",
|
||||
"noAccount": "Don't have an account?",
|
||||
"hasAccount": "Already have an account?",
|
||||
"marketingConsent": "I agree to receive occasional Memento news and offers (optional). Essential messages about my account will continue.",
|
||||
"signInToAccount": "Sign in to your account",
|
||||
"createAccount": "Create your account",
|
||||
"rememberMe": "Remember me",
|
||||
@@ -1092,6 +1105,11 @@
|
||||
"about": "About",
|
||||
"version": "Version",
|
||||
"settingsSaved": "Settings saved",
|
||||
"marketingTitle": "News and offers",
|
||||
"marketingDescription": "Receive occasional Memento news and offers. This is optional and separate from account, billing, and password messages.",
|
||||
"marketingLegal": "You can stop at any time. A campaign already handed to the mail service cannot be pulled back.",
|
||||
"marketingBlocked": "This address cannot receive announcements (complaint or permanent delivery failure).",
|
||||
"settingsSaveFailed": "Could not save this choice.",
|
||||
"cardSizeMode": "Note Size",
|
||||
"cardSizeModeDescription": "Choose between variable sizes or uniform size",
|
||||
"selectCardSizeMode": "Select display mode",
|
||||
@@ -1598,7 +1616,42 @@
|
||||
"chat": "AI Chat",
|
||||
"lab": "The Lab (Ideas)",
|
||||
"agents": "Agents",
|
||||
"settings": "Settings"
|
||||
"settings": "Settings",
|
||||
"marketing": "Marketing",
|
||||
"stats": "Statistics"
|
||||
},
|
||||
"marketing": {
|
||||
"title": "Marketing",
|
||||
"description": "Prepare campaigns, choose who receives them, and attach an offer. Sending is not enabled yet.",
|
||||
"emptyTitle": "No campaigns yet",
|
||||
"emptyBody": "When campaigns exist, they will appear in this table with their audience, offer, and status. Nothing is sent from this page.",
|
||||
"createCampaign": "Create a campaign",
|
||||
"createDisabledHint": "Campaign sending is not enabled in this version.",
|
||||
"columns": {
|
||||
"name": "Internal name",
|
||||
"goal": "Purpose",
|
||||
"audience": "Audience",
|
||||
"language": "Language",
|
||||
"offer": "Offer",
|
||||
"status": "Status",
|
||||
"scheduledAt": "Scheduled date",
|
||||
"accepted": "Accepted by the mail service",
|
||||
"delivered": "Delivered",
|
||||
"paidConversions": "Paid conversions"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"title": "Statistics",
|
||||
"description": "Revenue, subscriptions, product activity, and campaign results. Figures appear only from a reliable source.",
|
||||
"unavailable": "Not available yet",
|
||||
"unavailableBody": "This section is not connected to a measurement source. It does not show zero, because zero would mean “nothing happened”.",
|
||||
"lastSyncUnknown": "Last update: unknown",
|
||||
"sectionRevenue": "Revenue",
|
||||
"sectionSubscriptions": "Subscriptions",
|
||||
"sectionUsage": "Usage",
|
||||
"sectionCampaigns": "Campaigns",
|
||||
"periodLabel": "Period",
|
||||
"timezoneNote": "Dates follow the administrator’s time zone once measurements exist."
|
||||
},
|
||||
"metrics": {
|
||||
"vsLastPeriod": "vs last period"
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
{
|
||||
"unsubscribe": {
|
||||
"title": "¿Dejar de recibir novedades y ofertas?",
|
||||
"body": "Solo detiene anuncios y ofertas. Cuenta, factura y contraseña no cambian. Confirme con el botón: abrir esta página no basta.",
|
||||
"confirm": "Dejar de recibir anuncios",
|
||||
"keep": "Seguir recibiéndolos",
|
||||
"successTitle": "Baja registrada",
|
||||
"successBody": "Ya no recibirá novedades ni ofertas de Memento en esta dirección. Los mensajes imprescindibles del servicio pueden seguir llegando.",
|
||||
"invalidTitle": "Este enlace no es válido",
|
||||
"invalidBody": "Falta el enlace o ya no sirve. También puede dejar los anuncios en Ajustes.",
|
||||
"openSettings": "Abrir ajustes",
|
||||
"backToSignIn": "Iniciar sesión"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Iniciar sesión",
|
||||
"signUp": "Registrarse",
|
||||
@@ -14,6 +26,7 @@
|
||||
"forgotPassword": "¿Olvidaste tu contraseña?",
|
||||
"noAccount": "¿No tienes una cuenta?",
|
||||
"hasAccount": "¿Ya tienes una cuenta?",
|
||||
"marketingConsent": "Acepto recibir de vez en cuando novedades y ofertas de Memento (opcional). Los mensajes imprescindibles de la cuenta seguirán llegando.",
|
||||
"signInToAccount": "Inicia sesión en tu cuenta",
|
||||
"createAccount": "Crea tu cuenta",
|
||||
"rememberMe": "Recordarme",
|
||||
@@ -1031,6 +1044,11 @@
|
||||
"about": "Acerca de",
|
||||
"version": "Versión",
|
||||
"settingsSaved": "Ajustes guardados",
|
||||
"marketingTitle": "Novedades y ofertas",
|
||||
"marketingDescription": "Recibir de vez en cuando novedades y ofertas de Memento. Es opcional y distinto de los mensajes de cuenta, factura y contraseña.",
|
||||
"marketingLegal": "Puede dejarlo cuando quiera. Una campaña ya entregada al servicio de envío no se puede retirar.",
|
||||
"marketingBlocked": "Esta dirección no puede recibir anuncios (queja o fallo definitivo de entrega).",
|
||||
"settingsSaveFailed": "No se ha podido guardar esta elección.",
|
||||
"cardSizeMode": "Tamaño de nota",
|
||||
"cardSizeModeDescription": "Elige entre tamaños variables o tamaño uniforme",
|
||||
"selectCardSizeMode": "Selecciona el modo de visualización",
|
||||
@@ -1527,7 +1545,42 @@
|
||||
"agents": "Agentes",
|
||||
"settings": "Configuración",
|
||||
"billing": "Facturación y cuotas",
|
||||
"published": "Páginas publicadas"
|
||||
"published": "Páginas publicadas",
|
||||
"marketing": "Marketing",
|
||||
"stats": "Estadísticas"
|
||||
},
|
||||
"marketing": {
|
||||
"title": "Marketing",
|
||||
"description": "Preparar campañas, elegir destinatarios y adjuntar una oferta. El envío aún no está activado.",
|
||||
"emptyTitle": "Todavía no hay campañas",
|
||||
"emptyBody": "Cuando existan campañas, aparecerán en esta tabla con el público, la oferta y el estado. Esta página no envía correos.",
|
||||
"createCampaign": "Crear una campaña",
|
||||
"createDisabledHint": "El envío de campañas no está activado en esta versión.",
|
||||
"columns": {
|
||||
"name": "Nombre interno",
|
||||
"goal": "Objetivo",
|
||||
"audience": "Destinatarios",
|
||||
"language": "Idioma",
|
||||
"offer": "Oferta",
|
||||
"status": "Estado",
|
||||
"scheduledAt": "Fecha prevista",
|
||||
"accepted": "Aceptados por el servicio de envío",
|
||||
"delivered": "Entregados",
|
||||
"paidConversions": "Conversiones de pago"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"title": "Estadísticas",
|
||||
"description": "Ingresos, suscripciones, actividad del producto y resultados de campañas. Las cifras solo aparecen si vienen de una fuente fiable.",
|
||||
"unavailable": "Aún no disponible",
|
||||
"unavailableBody": "Esta parte no está conectada a una medición. No muestra un cero, porque un cero significaría que no ocurrió nada.",
|
||||
"lastSyncUnknown": "Última actualización: desconocida",
|
||||
"sectionRevenue": "Ingresos",
|
||||
"sectionSubscriptions": "Suscripciones",
|
||||
"sectionUsage": "Uso",
|
||||
"sectionCampaigns": "Campañas",
|
||||
"periodLabel": "Periodo",
|
||||
"timezoneNote": "Las fechas seguirán la zona horaria del administrador cuando haya mediciones."
|
||||
},
|
||||
"metrics": {
|
||||
"vsLastPeriod": "vs período anterior"
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
{
|
||||
"unsubscribe": {
|
||||
"title": "اخبار و پیشنهادها متوقف شود؟",
|
||||
"body": "فقط اعلامیهها و پیشنهادها قطع میشود. حساب، صورتحساب و گذرواژه عوض نمیشود. با دکمه تأیید کنید — باز کردن این صفحه کافی نیست.",
|
||||
"confirm": "قطع اعلامیهها",
|
||||
"keep": "دریافت ادامه یابد",
|
||||
"successTitle": "لغو ثبت شد",
|
||||
"successBody": "دیگر اخبار و پیشنهادهای Memento به این نشانی نمیرسد. پیامهای ضروری خدمت ممکن است همچنان برسند.",
|
||||
"invalidTitle": "این پیوند معتبر نیست",
|
||||
"invalidBody": "پیوند نیست یا کهنه است. از تنظیمات هم میتوانید اعلامیهها را قطع کنید.",
|
||||
"openSettings": "باز کردن تنظیمات",
|
||||
"backToSignIn": "ورود"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "ورود",
|
||||
"signUp": "ثبتنام",
|
||||
@@ -14,6 +26,7 @@
|
||||
"forgotPassword": "رمز عبور را فراموش کردهاید؟",
|
||||
"noAccount": "حساب کاربری ندارید؟",
|
||||
"hasAccount": "قبلاً ثبتنام کردهاید؟",
|
||||
"marketingConsent": "میپذیرم گاهی اخبار و پیشنهادهای Memento را دریافت کنم (اختیاری). پیامهای ضروری حساب ادامه دارند.",
|
||||
"signInToAccount": "به حساب کاربری خود وارد شوید",
|
||||
"createAccount": "ایجاد حساب کاربری",
|
||||
"rememberMe": "مرا به خاطر بسپار",
|
||||
@@ -1031,6 +1044,11 @@
|
||||
"about": "درباره",
|
||||
"version": "نسخه",
|
||||
"settingsSaved": "تنظیمات ذخیره شد",
|
||||
"marketingTitle": "اخبار و پیشنهادها",
|
||||
"marketingDescription": "گاهی اخبار و پیشنهادهای Memento را دریافت کنید. اجباری نیست و جدا از پیامهای حساب، صورتحساب و گذرواژه است.",
|
||||
"marketingLegal": "هر زمان میتوانید متوقف کنید. کارزاری که به سرویس ارسال سپرده شده قابل بازگرداندن نیست.",
|
||||
"marketingBlocked": "این نشانی نمیتواند اعلامیه دریافت کند (شکایت یا شکست قطعی تحویل).",
|
||||
"settingsSaveFailed": "این انتخاب ذخیره نشد.",
|
||||
"cardSizeMode": "اندازه یادداشت",
|
||||
"cardSizeModeDescription": "انتخاب بین اندازه متغیر یا یکنواخت",
|
||||
"selectCardSizeMode": "انتخاب حالت نمایش",
|
||||
@@ -1527,7 +1545,42 @@
|
||||
"agents": "عاملها",
|
||||
"settings": "تنظیمات",
|
||||
"billing": "صورتحساب و سهمیهها",
|
||||
"published": "صفحات منتشر شده"
|
||||
"published": "صفحات منتشر شده",
|
||||
"marketing": "بازاریابی",
|
||||
"stats": "آمار"
|
||||
},
|
||||
"marketing": {
|
||||
"title": "بازاریابی",
|
||||
"description": "آمادهسازی کارزارها، انتخاب گیرندگان و پیوست کردن پیشنهاد. ارسال هنوز فعال نیست.",
|
||||
"emptyTitle": "هنوز کارزاری نیست",
|
||||
"emptyBody": "وقتی کارزارها ساخته شوند در این جدول با مخاطب، پیشنهاد و وضعیت دیده میشوند. این صفحه نامهای نمیفرستد.",
|
||||
"createCampaign": "ساخت کارزار",
|
||||
"createDisabledHint": "ارسال کارزار در این نسخه فعال نیست.",
|
||||
"columns": {
|
||||
"name": "نام داخلی",
|
||||
"goal": "هدف",
|
||||
"audience": "گیرندگان",
|
||||
"language": "زبان",
|
||||
"offer": "پیشنهاد",
|
||||
"status": "وضعیت",
|
||||
"scheduledAt": "تاریخ برنامهریزیشده",
|
||||
"accepted": "پذیرفتهشده توسط سرویس ارسال",
|
||||
"delivered": "تحویلشده",
|
||||
"paidConversions": "تبدیلهای پرداختشده"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"title": "آمار",
|
||||
"description": "درآمد، اشتراکها، فعالیت محصول و نتیجهٔ کارزارها. عدد فقط از منبع قابل اعتماد نشان داده میشود.",
|
||||
"unavailable": "هنوز در دسترس نیست",
|
||||
"unavailableBody": "این بخش هنوز به اندازهگیری وصل نیست. صفر نشان داده نمیشود، چون صفر یعنی هیچ اتفاقی نیفتاده.",
|
||||
"lastSyncUnknown": "آخرین بهروزرسانی: نامعلوم",
|
||||
"sectionRevenue": "درآمد",
|
||||
"sectionSubscriptions": "اشتراکها",
|
||||
"sectionUsage": "استفاده",
|
||||
"sectionCampaigns": "کارزارها",
|
||||
"periodLabel": "بازه",
|
||||
"timezoneNote": "وقتی اندازهگیری باشد، تاریخها منطقهٔ زمانی مدیر را دنبال میکنند."
|
||||
},
|
||||
"metrics": {
|
||||
"vsLastPeriod": "در مقایسه با دوره قبل"
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
{
|
||||
"unsubscribe": {
|
||||
"title": "Arrêter les actualités et les offres ?",
|
||||
"body": "Cela n’arrête que les annonces et les offres. Les messages de compte, de facture et de mot de passe restent. Confirmez avec le bouton — ouvrir cette page ne suffit pas.",
|
||||
"confirm": "Arrêter les annonces",
|
||||
"keep": "Continuer à les recevoir",
|
||||
"successTitle": "C’est enregistré",
|
||||
"successBody": "Vous ne recevrez plus les actualités et offres Memento à cette adresse. Les messages indispensables au service peuvent encore arriver.",
|
||||
"invalidTitle": "Ce lien n’est pas valable",
|
||||
"invalidBody": "Le lien manque ou n’est plus bon. Vous pouvez aussi arrêter les annonces dans les paramètres.",
|
||||
"openSettings": "Ouvrir les paramètres",
|
||||
"backToSignIn": "Se connecter"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Connexion",
|
||||
"signUp": "S'inscrire",
|
||||
@@ -14,6 +26,7 @@
|
||||
"forgotPassword": "Mot de passe oublié ?",
|
||||
"noAccount": "Pas de compte ?",
|
||||
"hasAccount": "Déjà un compte ?",
|
||||
"marketingConsent": "J’accepte de recevoir, de temps en temps, des actualités et des offres de Memento (facultatif). Les messages indispensables sur mon compte continueront.",
|
||||
"signInToAccount": "Connectez-vous à votre compte",
|
||||
"createAccount": "Créez votre compte",
|
||||
"rememberMe": "Se souvenir de moi",
|
||||
@@ -1098,6 +1111,11 @@
|
||||
"about": "À propos",
|
||||
"version": "Version",
|
||||
"settingsSaved": "Paramètres enregistrés avec succès",
|
||||
"marketingTitle": "Actualités et offres",
|
||||
"marketingDescription": "Recevoir de temps en temps des nouvelles de Memento et des offres. Ce n’est pas obligatoire, et c’est distinct des messages de compte, de facture et de mot de passe.",
|
||||
"marketingLegal": "Vous pouvez arrêter à tout moment. Une campagne déjà confiée au service d’envoi ne peut pas être rappelée.",
|
||||
"marketingBlocked": "Cette adresse ne peut plus recevoir d’annonces (plainte ou échec définitif de livraison).",
|
||||
"settingsSaveFailed": "Ce choix n’a pas pu être enregistré.",
|
||||
"cardSizeMode": "Taille des notes",
|
||||
"cardSizeModeDescription": "Choisir entre des notes de tailles différentes ou uniformes",
|
||||
"selectCardSizeMode": "Sélectionner le mode d'affichage",
|
||||
@@ -1604,7 +1622,42 @@
|
||||
"chat": "Chat IA",
|
||||
"lab": "Le Lab (Idées)",
|
||||
"agents": "Agents",
|
||||
"settings": "Paramètres"
|
||||
"settings": "Paramètres",
|
||||
"marketing": "Marketing",
|
||||
"stats": "Statistiques"
|
||||
},
|
||||
"marketing": {
|
||||
"title": "Marketing",
|
||||
"description": "Préparer des campagnes, choisir les destinataires et joindre une offre. Les envois ne sont pas encore activés.",
|
||||
"emptyTitle": "Aucune campagne pour le moment",
|
||||
"emptyBody": "Quand des campagnes existeront, elles apparaîtront dans ce tableau avec le public, l’offre et l’état. Cette page n’envoie aucun courriel.",
|
||||
"createCampaign": "Créer une campagne",
|
||||
"createDisabledHint": "L’envoi des campagnes n’est pas activé dans cette version.",
|
||||
"columns": {
|
||||
"name": "Nom interne",
|
||||
"goal": "Objectif",
|
||||
"audience": "Destinataires",
|
||||
"language": "Langue",
|
||||
"offer": "Offre",
|
||||
"status": "État",
|
||||
"scheduledAt": "Date prévue",
|
||||
"accepted": "Acceptés par le service d’envoi",
|
||||
"delivered": "Délivrés",
|
||||
"paidConversions": "Conversions payées"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"title": "Statistiques",
|
||||
"description": "Revenus, abonnements, activité du produit et résultats des campagnes. Les chiffres n’apparaissent que s’ils viennent d’une source fiable.",
|
||||
"unavailable": "Pas encore disponible",
|
||||
"unavailableBody": "Cette partie n’est pas encore reliée à une mesure. Elle n’affiche pas zéro : un zéro voudrait dire « rien n’est arrivé ».",
|
||||
"lastSyncUnknown": "Dernière mise à jour : inconnue",
|
||||
"sectionRevenue": "Revenus",
|
||||
"sectionSubscriptions": "Abonnements",
|
||||
"sectionUsage": "Utilisation",
|
||||
"sectionCampaigns": "Campagnes",
|
||||
"periodLabel": "Période",
|
||||
"timezoneNote": "Les dates suivront le fuseau de l’administrateur dès qu’il y aura des mesures."
|
||||
},
|
||||
"metrics": {
|
||||
"vsLastPeriod": "vs période précédente"
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
{
|
||||
"unsubscribe": {
|
||||
"title": "खबरें और प्रस्ताव रोकें?",
|
||||
"body": "केवल घोषणाएँ और प्रस्ताव रुकते हैं। खाता, बिल, पासवर्ड नहीं बदलते। बटन से पुष्टि करें — पेज खोलना काफी नहीं।",
|
||||
"confirm": "घोषणाएँ रोकें",
|
||||
"keep": "इन्हें पाते रहें",
|
||||
"successTitle": "सदस्यता रद्द",
|
||||
"successBody": "इस पते पर Memento की खबरें और प्रस्ताव नहीं आएँगे। ज़रूरी सेवा पत्र आ सकते हैं।",
|
||||
"invalidTitle": "यह लिंक मान्य नहीं",
|
||||
"invalidBody": "लिंक नहीं है या पुराना है। सेटिंग में भी घोषणाएँ रोकी जा सकती हैं।",
|
||||
"openSettings": "सेटिंग खोलें",
|
||||
"backToSignIn": "साइन इन"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "साइन इन करें",
|
||||
"signUp": "साइन अप करें",
|
||||
@@ -14,6 +26,7 @@
|
||||
"forgotPassword": "पासवर्ड भूल गए?",
|
||||
"noAccount": "खाता नहीं है?",
|
||||
"hasAccount": "पहले से खाता है?",
|
||||
"marketingConsent": "मैं कभी-कभी Memento की खबरें और प्रस्ताव पाने के लिए सहमत हूँ (वैकल्पिक)। खाते के ज़रूरी पत्र आते रहेंगे।",
|
||||
"signInToAccount": "अपने खाते में साइन इन करें",
|
||||
"createAccount": "अपना खाता बनाएं",
|
||||
"rememberMe": "मुझे याद रखें",
|
||||
@@ -1031,6 +1044,11 @@
|
||||
"about": "के बारे में",
|
||||
"version": "संस्करण",
|
||||
"settingsSaved": "सेटिंग्स सहेजी गईं",
|
||||
"marketingTitle": "खबरें और प्रस्ताव",
|
||||
"marketingDescription": "कभी-कभी Memento की खबरें और प्रस्ताव पाएँ। यह ज़रूरी नहीं है और खाता, बिल, पासवर्ड के पत्रों से अलग है।",
|
||||
"marketingLegal": "आप कभी भी रोक सकते हैं। भेजने वाली सेवा को सौंपी गई अभियान वापस नहीं ली जा सकती।",
|
||||
"marketingBlocked": "यह पता घोषणाएँ नहीं पा सकता (शिकायत या स्थायी डिलीवरी विफलता)।",
|
||||
"settingsSaveFailed": "यह चयन सहेजा नहीं जा सका।",
|
||||
"cardSizeMode": "नोट आकार",
|
||||
"cardSizeModeDescription": "परिवर्तनीय या समान आकार चुनें",
|
||||
"selectCardSizeMode": "प्रदर्शन मोड चुनें",
|
||||
@@ -1527,7 +1545,42 @@
|
||||
"agents": "एजेंट",
|
||||
"settings": "सेटिंग्स",
|
||||
"billing": "बिलिंग और कोटा",
|
||||
"published": "प्रकाशित पृष्ठ"
|
||||
"published": "प्रकाशित पृष्ठ",
|
||||
"marketing": "मार्केटिंग",
|
||||
"stats": "आँकड़े"
|
||||
},
|
||||
"marketing": {
|
||||
"title": "मार्केटिंग",
|
||||
"description": "अभियान तैयार करें, प्राप्तकर्ता चुनें और एक प्रस्ताव जोड़ें। भेजना अभी चालू नहीं है।",
|
||||
"emptyTitle": "अभी कोई अभियान नहीं",
|
||||
"emptyBody": "जब अभियान होंगे, वे इस तालिका में दर्शकों, प्रस्ताव और स्थिति के साथ दिखेंगे। यह पृष्ठ कोई पत्र नहीं भेजता।",
|
||||
"createCampaign": "अभियान बनाएँ",
|
||||
"createDisabledHint": "इस संस्करण में अभियान भेजना चालू नहीं है।",
|
||||
"columns": {
|
||||
"name": "आंतरिक नाम",
|
||||
"goal": "उद्देश्य",
|
||||
"audience": "प्राप्तकर्ता",
|
||||
"language": "भाषा",
|
||||
"offer": "प्रस्ताव",
|
||||
"status": "स्थिति",
|
||||
"scheduledAt": "नियोजित तिथि",
|
||||
"accepted": "भेजने वाली सेवा ने स्वीकार किया",
|
||||
"delivered": "पहुँचाया गया",
|
||||
"paidConversions": "भुगतान वाली रूपांतरण"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"title": "आँकड़े",
|
||||
"description": "आय, सदस्यता, उत्पाद गतिविधि और अभियान परिणाम। संख्याएँ केवल विश्वसनीय स्रोत से दिखती हैं।",
|
||||
"unavailable": "अभी उपलब्ध नहीं",
|
||||
"unavailableBody": "यह भाग अभी माप से जुड़ा नहीं है। शून्य नहीं दिखाया जाता, क्योंकि शून्य का अर्थ होता “कुछ नहीं हुआ”।",
|
||||
"lastSyncUnknown": "अंतिम अपडेट: अज्ञात",
|
||||
"sectionRevenue": "आय",
|
||||
"sectionSubscriptions": "सदस्यता",
|
||||
"sectionUsage": "उपयोग",
|
||||
"sectionCampaigns": "अभियान",
|
||||
"periodLabel": "अवधि",
|
||||
"timezoneNote": "माप आने पर तिथियाँ व्यवस्थापक के समय क्षेत्र का पालन करेंगी।"
|
||||
},
|
||||
"metrics": {
|
||||
"vsLastPeriod": "vs पिछली अवधि"
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
{
|
||||
"unsubscribe": {
|
||||
"title": "Interrompere novità e offerte?",
|
||||
"body": "Si interrompono solo annunci e offerte. Account, fattura e password restano. Confermi con il pulsante: aprire questa pagina non basta.",
|
||||
"confirm": "Interrompi gli annunci",
|
||||
"keep": "Continua a riceverli",
|
||||
"successTitle": "Iscrizione annullata",
|
||||
"successBody": "Non riceverà più novità e offerte Memento a questo indirizzo. I messaggi indispensabili del servizio possono arrivare ancora.",
|
||||
"invalidTitle": "Questo collegamento non è valido",
|
||||
"invalidBody": "Manca il collegamento oppure non è più valido. Può interrompere gli annunci anche nelle impostazioni.",
|
||||
"openSettings": "Apri le impostazioni",
|
||||
"backToSignIn": "Accedi"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Accedi",
|
||||
"signUp": "Registrati",
|
||||
@@ -14,6 +26,7 @@
|
||||
"forgotPassword": "Password dimenticata?",
|
||||
"noAccount": "Non hai un account?",
|
||||
"hasAccount": "Hai già un account?",
|
||||
"marketingConsent": "Accetto di ricevere di tanto in tanto novità e offerte di Memento (facoltativo). I messaggi indispensabili sull’account continueranno.",
|
||||
"signInToAccount": "Accedi al tuo account",
|
||||
"createAccount": "Crea il tuo account",
|
||||
"rememberMe": "Ricordami",
|
||||
@@ -1031,6 +1044,11 @@
|
||||
"about": "Informazioni",
|
||||
"version": "Versione",
|
||||
"settingsSaved": "Impostazioni salvate",
|
||||
"marketingTitle": "Novità e offerte",
|
||||
"marketingDescription": "Ricevere di tanto in tanto novità e offerte di Memento. Facoltativo e distinto dai messaggi di account, fattura e password.",
|
||||
"marketingLegal": "Può interrompere in qualsiasi momento. Una campagna già affidata al servizio di invio non può essere richiamata.",
|
||||
"marketingBlocked": "Questo indirizzo non può ricevere annunci (reclamo o errore definitivo di consegna).",
|
||||
"settingsSaveFailed": "Non è stato possibile salvare questa scelta.",
|
||||
"cardSizeMode": "Dimensione nota",
|
||||
"cardSizeModeDescription": "Scegli tra dimensioni variabili o uniformi",
|
||||
"selectCardSizeMode": "Seleziona modalità di visualizzazione",
|
||||
@@ -1527,7 +1545,42 @@
|
||||
"agents": "Agenti",
|
||||
"settings": "Impostazioni",
|
||||
"billing": "Fatturazione e quote",
|
||||
"published": "Pagine pubblicate"
|
||||
"published": "Pagine pubblicate",
|
||||
"marketing": "Marketing",
|
||||
"stats": "Statistiche"
|
||||
},
|
||||
"marketing": {
|
||||
"title": "Marketing",
|
||||
"description": "Preparare campagne, scegliere i destinatari e allegare un’offerta. L’invio non è ancora attivo.",
|
||||
"emptyTitle": "Nessuna campagna per ora",
|
||||
"emptyBody": "Quando ci saranno campagne, compariranno in questa tabella con destinatari, offerta e stato. Questa pagina non invia e-mail.",
|
||||
"createCampaign": "Crea una campagna",
|
||||
"createDisabledHint": "L’invio delle campagne non è attivo in questa versione.",
|
||||
"columns": {
|
||||
"name": "Nome interno",
|
||||
"goal": "Obiettivo",
|
||||
"audience": "Destinatari",
|
||||
"language": "Lingua",
|
||||
"offer": "Offerta",
|
||||
"status": "Stato",
|
||||
"scheduledAt": "Data prevista",
|
||||
"accepted": "Accettati dal servizio di invio",
|
||||
"delivered": "Consegnati",
|
||||
"paidConversions": "Conversioni a pagamento"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"title": "Statistiche",
|
||||
"description": "Ricavi, abbonamenti, attività del prodotto e risultati delle campagne. Le cifre appaiono solo da una fonte affidabile.",
|
||||
"unavailable": "Non ancora disponibile",
|
||||
"unavailableBody": "Questa sezione non è collegata a una misura. Non mostra zero: zero vorrebbe dire che non è successo nulla.",
|
||||
"lastSyncUnknown": "Ultimo aggiornamento: sconosciuto",
|
||||
"sectionRevenue": "Ricavi",
|
||||
"sectionSubscriptions": "Abbonamenti",
|
||||
"sectionUsage": "Utilizzo",
|
||||
"sectionCampaigns": "Campagne",
|
||||
"periodLabel": "Periodo",
|
||||
"timezoneNote": "Le date seguiranno il fuso dell’amministratore quando ci saranno misure."
|
||||
},
|
||||
"metrics": {
|
||||
"vsLastPeriod": "vs periodo precedente"
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
{
|
||||
"unsubscribe": {
|
||||
"title": "近況と特典を止めますか?",
|
||||
"body": "止まるのは案内と特典だけです。アカウント、請求、パスワードは変わりません。ボタンで確定してください。このページを開いただけでは止まりません。",
|
||||
"confirm": "案内を止める",
|
||||
"keep": "受け取りを続ける",
|
||||
"successTitle": "配信を止めました",
|
||||
"successBody": "この宛先に Memento の近況や特典は届かなくなります。必要なサービス連絡は続くことがあります。",
|
||||
"invalidTitle": "このリンクは使えません",
|
||||
"invalidBody": "リンクがないか、古くなっています。設定からも案内を止められます。",
|
||||
"openSettings": "設定を開く",
|
||||
"backToSignIn": "ログイン"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "ログイン",
|
||||
"signUp": "新規登録",
|
||||
@@ -14,6 +26,7 @@
|
||||
"forgotPassword": "パスワードをお忘れですか?",
|
||||
"noAccount": "アカウントをお持ちでない方",
|
||||
"hasAccount": "すでにアカウントをお持ちの方",
|
||||
"marketingConsent": "Memento の近況や特典をときどき受け取ることに同意します(任意)。アカウントに必要な連絡は続きます。",
|
||||
"signInToAccount": "アカウントにログイン",
|
||||
"createAccount": "アカウントを作成",
|
||||
"rememberMe": "ログイン状態を保持",
|
||||
@@ -1031,6 +1044,11 @@
|
||||
"about": "について",
|
||||
"version": "バージョン",
|
||||
"settingsSaved": "設定を保存しました",
|
||||
"marketingTitle": "近況と特典",
|
||||
"marketingDescription": "Memento の近況や特典をときどき受け取ります。任意で、アカウント・請求・パスワードの連絡とは別です。",
|
||||
"marketingLegal": "いつでも止められます。すでに送信サービスへ渡した案内は取り下げできません。",
|
||||
"marketingBlocked": "この宛先は案内を受け取れません(苦情または配達の確定失敗)。",
|
||||
"settingsSaveFailed": "この選択を保存できませんでした。",
|
||||
"cardSizeMode": "ノートサイズ",
|
||||
"cardSizeModeDescription": "可変サイズまたは均一サイズを選択",
|
||||
"selectCardSizeMode": "表示モードを選択",
|
||||
@@ -1527,7 +1545,42 @@
|
||||
"agents": "エージェント",
|
||||
"settings": "設定",
|
||||
"billing": "請求とクォータ",
|
||||
"published": "公開済みページ"
|
||||
"published": "公開済みページ",
|
||||
"marketing": "マーケティング",
|
||||
"stats": "統計"
|
||||
},
|
||||
"marketing": {
|
||||
"title": "マーケティング",
|
||||
"description": "キャンペーンを用意し、宛先を選び、特典を付けます。送信はまだ有効ではありません。",
|
||||
"emptyTitle": "キャンペーンはまだありません",
|
||||
"emptyBody": "キャンペーンができると、宛先・特典・状態とともにこの表に出ます。このページからメールは送られません。",
|
||||
"createCampaign": "キャンペーンを作成",
|
||||
"createDisabledHint": "この版ではキャンペーン送信は有効ではありません。",
|
||||
"columns": {
|
||||
"name": "内部名",
|
||||
"goal": "目的",
|
||||
"audience": "宛先",
|
||||
"language": "言語",
|
||||
"offer": "特典",
|
||||
"status": "状態",
|
||||
"scheduledAt": "予定日",
|
||||
"accepted": "送信サービスが受け付け",
|
||||
"delivered": "配達済み",
|
||||
"paidConversions": "有料の成果"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"title": "統計",
|
||||
"description": "売上、定期契約、製品の利用、キャンペーン結果。数字は信頼できる出典があるときだけ出ます。",
|
||||
"unavailable": "まだ利用できません",
|
||||
"unavailableBody": "この欄はまだ計測とつながっていません。ゼロは出しません。ゼロは「何も起きなかった」という意味になるためです。",
|
||||
"lastSyncUnknown": "最終更新:不明",
|
||||
"sectionRevenue": "売上",
|
||||
"sectionSubscriptions": "定期契約",
|
||||
"sectionUsage": "利用",
|
||||
"sectionCampaigns": "キャンペーン",
|
||||
"periodLabel": "期間",
|
||||
"timezoneNote": "計測が始まると、日付は管理者の時間帯に従います。"
|
||||
},
|
||||
"metrics": {
|
||||
"vsLastPeriod": "前回期間比"
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
{
|
||||
"unsubscribe": {
|
||||
"title": "소식과 혜택을 멈출까요?",
|
||||
"body": "안내와 혜택만 멈춥니다. 계정, 청구, 비밀번호는 그대로입니다. 버튼으로 확인하세요. 이 페이지만 열어서는 멈추지 않습니다.",
|
||||
"confirm": "안내 멈추기",
|
||||
"keep": "계속 받기",
|
||||
"successTitle": "수신이 중단되었습니다",
|
||||
"successBody": "이 주소로는 더 이상 Memento 소식과 혜택이 오지 않습니다. 꼭 필요한 서비스 메일은 올 수 있습니다.",
|
||||
"invalidTitle": "이 링크는 유효하지 않습니다",
|
||||
"invalidBody": "링크가 없거나 오래되었습니다. 설정에서도 안내를 멈출 수 있습니다.",
|
||||
"openSettings": "설정 열기",
|
||||
"backToSignIn": "로그인"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "로그인",
|
||||
"signUp": "회원가입",
|
||||
@@ -14,6 +26,7 @@
|
||||
"forgotPassword": "비밀번호를 잊으셨나요?",
|
||||
"noAccount": "계정이 없으신가요?",
|
||||
"hasAccount": "이미 계정이 있으신가요?",
|
||||
"marketingConsent": "Memento 소식과 혜택을 가끔 받는 데 동의합니다(선택). 계정에 꼭 필요한 메일은 계속 옵니다.",
|
||||
"signInToAccount": "계정에 로그인하세요",
|
||||
"createAccount": "계정 만들기",
|
||||
"rememberMe": "로그인 상태 유지",
|
||||
@@ -1031,6 +1044,11 @@
|
||||
"about": "정보",
|
||||
"version": "버전",
|
||||
"settingsSaved": "설정이 저장되었습니다",
|
||||
"marketingTitle": "소식과 혜택",
|
||||
"marketingDescription": "Memento 소식과 혜택을 가끔 받습니다. 필수는 아니며 계정, 청구, 비밀번호 메일과는 다릅니다.",
|
||||
"marketingLegal": "언제든 멈출 수 있습니다. 이미 발송 서비스에 넘긴 안내는 거둘 수 없습니다.",
|
||||
"marketingBlocked": "이 주소는 안내를 받을 수 없습니다(신고 또는 영구 배달 실패).",
|
||||
"settingsSaveFailed": "이 선택을 저장하지 못했습니다.",
|
||||
"cardSizeMode": "노트 크기",
|
||||
"cardSizeModeDescription": "가변 크기 또는 균일한 크기中选择",
|
||||
"selectCardSizeMode": "표시 모드 선택",
|
||||
@@ -1527,7 +1545,42 @@
|
||||
"agents": "에이전트",
|
||||
"settings": "설정",
|
||||
"billing": "결제 및 할당량",
|
||||
"published": "게시된 페이지"
|
||||
"published": "게시된 페이지",
|
||||
"marketing": "마케팅",
|
||||
"stats": "통계"
|
||||
},
|
||||
"marketing": {
|
||||
"title": "마케팅",
|
||||
"description": "캠페인을 준비하고 받는 사람을 고르며 혜택을 붙입니다. 보내기는 아직 켜져 있지 않습니다.",
|
||||
"emptyTitle": "아직 캠페인이 없습니다",
|
||||
"emptyBody": "캠페인이 생기면 대상, 혜택, 상태와 함께 이 표에 나타납니다. 이 페이지에서는 메일을 보내지 않습니다.",
|
||||
"createCampaign": "캠페인 만들기",
|
||||
"createDisabledHint": "이 버전에서는 캠페인 보내기가 켜져 있지 않습니다.",
|
||||
"columns": {
|
||||
"name": "내부 이름",
|
||||
"goal": "목적",
|
||||
"audience": "받는 사람",
|
||||
"language": "언어",
|
||||
"offer": "혜택",
|
||||
"status": "상태",
|
||||
"scheduledAt": "예정일",
|
||||
"accepted": "발송 서비스가 수락",
|
||||
"delivered": "전달됨",
|
||||
"paidConversions": "유료 전환"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"title": "통계",
|
||||
"description": "매출, 구독, 제품 이용, 캠페인 결과. 숫자는 믿을 수 있는 출처가 있을 때만 나옵니다.",
|
||||
"unavailable": "아직 없음",
|
||||
"unavailableBody": "이 부분은 아직 측정과 연결되지 않았습니다. 0을 보여 주지 않습니다. 0은 “아무 일도 없었다”는 뜻이기 때문입니다.",
|
||||
"lastSyncUnknown": "마지막 업데이트: 알 수 없음",
|
||||
"sectionRevenue": "매출",
|
||||
"sectionSubscriptions": "구독",
|
||||
"sectionUsage": "이용",
|
||||
"sectionCampaigns": "캠페인",
|
||||
"periodLabel": "기간",
|
||||
"timezoneNote": "측정이 생기면 날짜는 관리자 시간대를 따릅니다."
|
||||
},
|
||||
"metrics": {
|
||||
"vsLastPeriod": "이전 기간 대비"
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
{
|
||||
"unsubscribe": {
|
||||
"title": "Nieuws en aanbiedingen stoppen?",
|
||||
"body": "Dit stopt alleen aankondigingen en aanbiedingen. Account, factuur en wachtwoord blijven. Bevestig met de knop — deze pagina openen volstaat niet.",
|
||||
"confirm": "Aankondigingen stoppen",
|
||||
"keep": "Blijven ontvangen",
|
||||
"successTitle": "Uitgeschreven",
|
||||
"successBody": "U ontvangt op dit adres geen Memento-nieuws en aanbiedingen meer. Essentiële dienstberichten kunnen nog komen.",
|
||||
"invalidTitle": "Deze koppeling is ongeldig",
|
||||
"invalidBody": "De koppeling ontbreekt of is verouderd. U kunt aankondigingen ook in Instellingen stoppen.",
|
||||
"openSettings": "Instellingen openen",
|
||||
"backToSignIn": "Aanmelden"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Inloggen",
|
||||
"signUp": "Registreren",
|
||||
@@ -14,6 +26,7 @@
|
||||
"forgotPassword": "Wachtwoord vergeten?",
|
||||
"noAccount": "Heeft u geen account?",
|
||||
"hasAccount": "Heeft u al een account?",
|
||||
"marketingConsent": "Ik ga akkoord om af en toe nieuws en aanbiedingen van Memento te ontvangen (optioneel). Essentiële berichten over mijn account blijven komen.",
|
||||
"signInToAccount": "Log in op uw account",
|
||||
"createAccount": "Maak uw account",
|
||||
"rememberMe": "Onthoud mij",
|
||||
@@ -1031,6 +1044,11 @@
|
||||
"about": "Over",
|
||||
"version": "Versie",
|
||||
"settingsSaved": "Instellingen opgeslagen",
|
||||
"marketingTitle": "Nieuws en aanbiedingen",
|
||||
"marketingDescription": "Af en toe nieuws en aanbiedingen van Memento. Optioneel en gescheiden van account-, factuur- en wachtwoordberichten.",
|
||||
"marketingLegal": "U kunt dit altijd stopzetten. Een campagne die al bij de verzenddienst ligt, kan niet worden teruggehaald.",
|
||||
"marketingBlocked": "Dit adres kan geen aankondigingen ontvangen (klacht of blijvende bezorgfout).",
|
||||
"settingsSaveFailed": "Deze keuze kon niet worden opgeslagen.",
|
||||
"cardSizeMode": "Notitiegrootte",
|
||||
"cardSizeModeDescription": "Kies tussen variabele of uniforme groottes",
|
||||
"selectCardSizeMode": "Weergavemodus selecteren",
|
||||
@@ -1527,7 +1545,42 @@
|
||||
"agents": "Agenten",
|
||||
"settings": "Instellingen",
|
||||
"billing": "Facturering & quota's",
|
||||
"published": "Gepubliceerde pagina's"
|
||||
"published": "Gepubliceerde pagina's",
|
||||
"marketing": "Marketing",
|
||||
"stats": "Statistieken"
|
||||
},
|
||||
"marketing": {
|
||||
"title": "Marketing",
|
||||
"description": "Campagnes voorbereiden, ontvangers kiezen en een aanbieding toevoegen. Verzenden is nog niet ingeschakeld.",
|
||||
"emptyTitle": "Nog geen campagnes",
|
||||
"emptyBody": "Zodra er campagnes zijn, verschijnen ze in deze tabel met publiek, aanbieding en status. Deze pagina verstuurt geen e-mail.",
|
||||
"createCampaign": "Campagne maken",
|
||||
"createDisabledHint": "Het verzenden van campagnes is in deze versie niet actief.",
|
||||
"columns": {
|
||||
"name": "Interne naam",
|
||||
"goal": "Doel",
|
||||
"audience": "Ontvangers",
|
||||
"language": "Taal",
|
||||
"offer": "Aanbieding",
|
||||
"status": "Status",
|
||||
"scheduledAt": "Geplande datum",
|
||||
"accepted": "Geaccepteerd door de verzenddienst",
|
||||
"delivered": "Bezorgd",
|
||||
"paidConversions": "Betaalde conversies"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"title": "Statistieken",
|
||||
"description": "Omzet, abonnementen, productgebruik en campagneresultaten. Cijfers verschijnen alleen uit een betrouwbare bron.",
|
||||
"unavailable": "Nog niet beschikbaar",
|
||||
"unavailableBody": "Dit deel is nog niet gekoppeld aan een meting. Er staat geen nul, omdat nul zou betekenen dat er niets is gebeurd.",
|
||||
"lastSyncUnknown": "Laatste update: onbekend",
|
||||
"sectionRevenue": "Omzet",
|
||||
"sectionSubscriptions": "Abonnementen",
|
||||
"sectionUsage": "Gebruik",
|
||||
"sectionCampaigns": "Campagnes",
|
||||
"periodLabel": "Periode",
|
||||
"timezoneNote": "Datums volgen de tijdzone van de beheerder zodra er metingen zijn."
|
||||
},
|
||||
"metrics": {
|
||||
"vsLastPeriod": "vs vorige periode"
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
{
|
||||
"unsubscribe": {
|
||||
"title": "Zatrzymać aktualności i oferty?",
|
||||
"body": "To wstrzymuje tylko ogłoszenia i oferty. Konto, rachunek i hasło zostają. Potwierdź przyciskiem — samo otwarcie strony nie wystarczy.",
|
||||
"confirm": "Zatrzymaj ogłoszenia",
|
||||
"keep": "Nadal je otrzymuj",
|
||||
"successTitle": "Wypisano",
|
||||
"successBody": "Nie będziesz już otrzymywać aktualności i ofert Memento na ten adres. Niezbędne wiadomości usługi mogą nadal przychodzić.",
|
||||
"invalidTitle": "Ten odnośnik jest nieważny",
|
||||
"invalidBody": "Brakuje odnośnika albo jest nieaktualny. Ogłoszenia możesz też zatrzymać w ustawieniach.",
|
||||
"openSettings": "Otwórz ustawienia",
|
||||
"backToSignIn": "Zaloguj się"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Zaloguj się",
|
||||
"signUp": "Zarejestruj się",
|
||||
@@ -14,6 +26,7 @@
|
||||
"forgotPassword": "Zapomniałeś hasła?",
|
||||
"noAccount": "Nie masz konta?",
|
||||
"hasAccount": "Masz już konto?",
|
||||
"marketingConsent": "Wyrażam zgodę na okazjonalne wiadomości i oferty Memento (opcjonalnie). Niezbędne wiadomości o koncie będą nadal wysyłane.",
|
||||
"signInToAccount": "Zaloguj się na swoje konto",
|
||||
"createAccount": "Utwórz swoje konto",
|
||||
"rememberMe": "Zapamiętaj mnie",
|
||||
@@ -1031,6 +1044,11 @@
|
||||
"about": "O aplikacji",
|
||||
"version": "Wersja",
|
||||
"settingsSaved": "Ustawienia zapisane",
|
||||
"marketingTitle": "Aktualności i oferty",
|
||||
"marketingDescription": "Okazjonalne aktualności i oferty Memento. To nieobowiązkowe i osobne od wiadomości o koncie, rachunku i haśle.",
|
||||
"marketingLegal": "Możesz zrezygnować w każdej chwili. Kampanii już przekazanej usłudze wysyłki nie da się wycofać.",
|
||||
"marketingBlocked": "Ten adres nie może otrzymywać ogłoszeń (skarga lub trwały błąd doręczenia).",
|
||||
"settingsSaveFailed": "Nie udało się zapisać tego wyboru.",
|
||||
"cardSizeMode": "Rozmiar notatki",
|
||||
"cardSizeModeDescription": "Wybierz zmienne lub jednolite rozmiary",
|
||||
"selectCardSizeMode": "Wybierz tryb wyświetlania",
|
||||
@@ -1527,7 +1545,42 @@
|
||||
"agents": "Agenci",
|
||||
"settings": "Ustawienia",
|
||||
"billing": "Rozliczenia i limity",
|
||||
"published": "Opublikowane strony"
|
||||
"published": "Opublikowane strony",
|
||||
"marketing": "Marketing",
|
||||
"stats": "Statystyki"
|
||||
},
|
||||
"marketing": {
|
||||
"title": "Marketing",
|
||||
"description": "Przygotuj kampanie, wybierz odbiorców i dołącz ofertę. Wysyłka nie jest jeszcze włączona.",
|
||||
"emptyTitle": "Brak kampanii",
|
||||
"emptyBody": "Gdy kampanie powstaną, pojawią się w tej tabeli z odbiorcami, ofertą i statusem. Ta strona nie wysyła wiadomości.",
|
||||
"createCampaign": "Utwórz kampanię",
|
||||
"createDisabledHint": "Wysyłka kampanii nie jest włączona w tej wersji.",
|
||||
"columns": {
|
||||
"name": "Nazwa wewnętrzna",
|
||||
"goal": "Cel",
|
||||
"audience": "Odbiorcy",
|
||||
"language": "Język",
|
||||
"offer": "Oferta",
|
||||
"status": "Status",
|
||||
"scheduledAt": "Planowana data",
|
||||
"accepted": "Przyjęte przez usługę wysyłki",
|
||||
"delivered": "Dostarczone",
|
||||
"paidConversions": "Płatne konwersje"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"title": "Statystyki",
|
||||
"description": "Przychody, subskrypcje, aktywność produktu i wyniki kampanii. Liczby pojawiają się tylko z wiarygodnego źródła.",
|
||||
"unavailable": "Jeszcze niedostępne",
|
||||
"unavailableBody": "Ta część nie jest jeszcze połączona z pomiarem. Nie pokazuje zera, bo zero oznaczałoby, że nic się nie stało.",
|
||||
"lastSyncUnknown": "Ostatnia aktualizacja: nieznana",
|
||||
"sectionRevenue": "Przychody",
|
||||
"sectionSubscriptions": "Subskrypcje",
|
||||
"sectionUsage": "Użycie",
|
||||
"sectionCampaigns": "Kampanie",
|
||||
"periodLabel": "Okres",
|
||||
"timezoneNote": "Daty będą zgodne ze strefą administratora, gdy pojawią się pomiary."
|
||||
},
|
||||
"metrics": {
|
||||
"vsLastPeriod": "względem poprzedniego okresu"
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
{
|
||||
"unsubscribe": {
|
||||
"title": "Parar novidades e ofertas?",
|
||||
"body": "Isto só pára anúncios e ofertas. Conta, fatura e palavra-passe ficam. Confirme com o botão — abrir esta página não chega.",
|
||||
"confirm": "Parar os anúncios",
|
||||
"keep": "Continuar a recebê-los",
|
||||
"successTitle": "Anulação registada",
|
||||
"successBody": "Deixará de receber novidades e ofertas da Memento neste endereço. As mensagens indispensáveis do serviço podem continuar a chegar.",
|
||||
"invalidTitle": "Esta ligação não é válida",
|
||||
"invalidBody": "Falta a ligação ou já não serve. Também pode parar os anúncios nas definições.",
|
||||
"openSettings": "Abrir definições",
|
||||
"backToSignIn": "Iniciar sessão"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Entrar",
|
||||
"signUp": "Cadastrar-se",
|
||||
@@ -14,6 +26,7 @@
|
||||
"forgotPassword": "Esqueceu sua senha?",
|
||||
"noAccount": "Não tem uma conta?",
|
||||
"hasAccount": "Já tem uma conta?",
|
||||
"marketingConsent": "Aceito receber de vez em quando novidades e ofertas da Memento (opcional). As mensagens indispensáveis da conta continuam.",
|
||||
"signInToAccount": "Entre na sua conta",
|
||||
"createAccount": "Crie sua conta",
|
||||
"rememberMe": "Lembrar-me",
|
||||
@@ -1031,6 +1044,11 @@
|
||||
"about": "Sobre",
|
||||
"version": "Versão",
|
||||
"settingsSaved": "Definições guardadas",
|
||||
"marketingTitle": "Novidades e ofertas",
|
||||
"marketingDescription": "Receber de vez em quando novidades e ofertas da Memento. É opcional e distinto das mensagens de conta, fatura e palavra-passe.",
|
||||
"marketingLegal": "Pode parar a qualquer momento. Uma campanha já entregue ao serviço de envio não pode ser retirada.",
|
||||
"marketingBlocked": "Este endereço não pode receber anúncios (queixa ou falha definitiva de entrega).",
|
||||
"settingsSaveFailed": "Não foi possível guardar esta escolha.",
|
||||
"cardSizeMode": "Tamanho da nota",
|
||||
"cardSizeModeDescription": "Escolha entre tamanhos variáveis ou uniformes",
|
||||
"selectCardSizeMode": "Selecione o modo de exibição",
|
||||
@@ -1527,7 +1545,42 @@
|
||||
"agents": "Agentes",
|
||||
"settings": "Configurações",
|
||||
"billing": "Faturação e cotas",
|
||||
"published": "Páginas publicadas"
|
||||
"published": "Páginas publicadas",
|
||||
"marketing": "Marketing",
|
||||
"stats": "Estatísticas"
|
||||
},
|
||||
"marketing": {
|
||||
"title": "Marketing",
|
||||
"description": "Preparar campanhas, escolher destinatários e anexar uma oferta. O envio ainda não está ativo.",
|
||||
"emptyTitle": "Ainda não há campanhas",
|
||||
"emptyBody": "Quando existirem campanhas, elas aparecerão nesta tabela com o público, a oferta e o estado. Esta página não envia e-mails.",
|
||||
"createCampaign": "Criar uma campanha",
|
||||
"createDisabledHint": "O envio de campanhas não está ativo nesta versão.",
|
||||
"columns": {
|
||||
"name": "Nome interno",
|
||||
"goal": "Objetivo",
|
||||
"audience": "Destinatários",
|
||||
"language": "Idioma",
|
||||
"offer": "Oferta",
|
||||
"status": "Estado",
|
||||
"scheduledAt": "Data prevista",
|
||||
"accepted": "Aceites pelo serviço de envio",
|
||||
"delivered": "Entregues",
|
||||
"paidConversions": "Conversões pagas"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"title": "Estatísticas",
|
||||
"description": "Receita, assinaturas, atividade do produto e resultados das campanhas. Os números só aparecem a partir de uma fonte fiável.",
|
||||
"unavailable": "Ainda não disponível",
|
||||
"unavailableBody": "Esta parte ainda não está ligada a uma medição. Não mostra zero, porque zero significaria que nada aconteceu.",
|
||||
"lastSyncUnknown": "Última atualização: desconhecida",
|
||||
"sectionRevenue": "Receita",
|
||||
"sectionSubscriptions": "Assinaturas",
|
||||
"sectionUsage": "Utilização",
|
||||
"sectionCampaigns": "Campanhas",
|
||||
"periodLabel": "Período",
|
||||
"timezoneNote": "As datas seguirão o fuso do administrador quando houver medições."
|
||||
},
|
||||
"metrics": {
|
||||
"vsLastPeriod": "em relação ao período anterior"
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
{
|
||||
"unsubscribe": {
|
||||
"title": "Отключить новости и предложения?",
|
||||
"body": "Это останавливает только объявления и предложения. Аккаунт, счёт и пароль не меняются. Подтвердите кнопкой — открыть страницу недостаточно.",
|
||||
"confirm": "Отключить объявления",
|
||||
"keep": "Продолжить получать",
|
||||
"successTitle": "Отписка оформлена",
|
||||
"successBody": "На этот адрес больше не будут приходить новости и предложения Memento. Обязательные служебные письма могут продолжить приходить.",
|
||||
"invalidTitle": "Эта ссылка недействительна",
|
||||
"invalidBody": "Ссылки нет или она устарела. Объявления можно отключить и в настройках.",
|
||||
"openSettings": "Открыть настройки",
|
||||
"backToSignIn": "Войти"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "Войти",
|
||||
"signUp": "Зарегистрироваться",
|
||||
@@ -14,6 +26,7 @@
|
||||
"forgotPassword": "Забыли пароль?",
|
||||
"noAccount": "Нет аккаунта?",
|
||||
"hasAccount": "Уже есть аккаунт?",
|
||||
"marketingConsent": "Я соглашаюсь иногда получать новости и предложения Memento (по желанию). Обязательные письма об аккаунте продолжат приходить.",
|
||||
"signInToAccount": "Войдите в свой аккаунт",
|
||||
"createAccount": "Создайте свой аккаунт",
|
||||
"rememberMe": "Запомнить меня",
|
||||
@@ -1031,6 +1044,11 @@
|
||||
"about": "О программе",
|
||||
"version": "Версия",
|
||||
"settingsSaved": "Настройки сохранены",
|
||||
"marketingTitle": "Новости и предложения",
|
||||
"marketingDescription": "Иногда получать новости и предложения Memento. Это необязательно и отдельно от писем об аккаунте, счёте и пароле.",
|
||||
"marketingLegal": "Можно отказаться в любой момент. Кампанию, уже переданную службе отправки, отозвать нельзя.",
|
||||
"marketingBlocked": "Этот адрес не может получать объявления (жалоба или окончательная ошибка доставки).",
|
||||
"settingsSaveFailed": "Не удалось сохранить этот выбор.",
|
||||
"cardSizeMode": "Размер заметки",
|
||||
"cardSizeModeDescription": "Выберите переменные или одинаковые размеры",
|
||||
"selectCardSizeMode": "Выберите режим отображения",
|
||||
@@ -1527,7 +1545,42 @@
|
||||
"agents": "Агенты",
|
||||
"settings": "Настройки",
|
||||
"billing": "Биллинг и квоты",
|
||||
"published": "Опубликованные страницы"
|
||||
"published": "Опубликованные страницы",
|
||||
"marketing": "Маркетинг",
|
||||
"stats": "Статистика"
|
||||
},
|
||||
"marketing": {
|
||||
"title": "Маркетинг",
|
||||
"description": "Готовить рассылки, выбирать получателей и прикреплять предложение. Отправка ещё не включена.",
|
||||
"emptyTitle": "Пока нет кампаний",
|
||||
"emptyBody": "Когда кампании появятся, они будут в этой таблице с аудиторией, предложением и статусом. Эта страница не отправляет письма.",
|
||||
"createCampaign": "Создать кампанию",
|
||||
"createDisabledHint": "Отправка кампаний в этой версии не включена.",
|
||||
"columns": {
|
||||
"name": "Внутреннее имя",
|
||||
"goal": "Цель",
|
||||
"audience": "Получатели",
|
||||
"language": "Язык",
|
||||
"offer": "Предложение",
|
||||
"status": "Статус",
|
||||
"scheduledAt": "Запланированная дата",
|
||||
"accepted": "Принято службой отправки",
|
||||
"delivered": "Доставлено",
|
||||
"paidConversions": "Оплаченные конверсии"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"title": "Статистика",
|
||||
"description": "Выручка, подписки, активность в продукте и результаты кампаний. Цифры появляются только из надёжного источника.",
|
||||
"unavailable": "Пока недоступно",
|
||||
"unavailableBody": "Этот раздел ещё не связан с измерением. Ноль не показывается: ноль означал бы, что ничего не произошло.",
|
||||
"lastSyncUnknown": "Последнее обновление: неизвестно",
|
||||
"sectionRevenue": "Выручка",
|
||||
"sectionSubscriptions": "Подписки",
|
||||
"sectionUsage": "Использование",
|
||||
"sectionCampaigns": "Кампании",
|
||||
"periodLabel": "Период",
|
||||
"timezoneNote": "Даты будут в часовом поясе администратора, когда появятся измерения."
|
||||
},
|
||||
"metrics": {
|
||||
"vsLastPeriod": "к предыдущему периоду"
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
{
|
||||
"unsubscribe": {
|
||||
"title": "停止近况和优惠?",
|
||||
"body": "这只停止公告和优惠。账户、账单、密码邮件不变。请用按钮确认——打开本页不够。",
|
||||
"confirm": "停止公告",
|
||||
"keep": "继续接收",
|
||||
"successTitle": "已取消订阅",
|
||||
"successBody": "此地址将不再收到 Memento 近况和优惠。必要的服务邮件仍可能到达。",
|
||||
"invalidTitle": "此链接无效",
|
||||
"invalidBody": "链接缺失或已过期。也可以在设置中停止公告。",
|
||||
"openSettings": "打开设置",
|
||||
"backToSignIn": "登录"
|
||||
},
|
||||
"auth": {
|
||||
"signIn": "登录",
|
||||
"signUp": "注册",
|
||||
@@ -14,6 +26,7 @@
|
||||
"forgotPassword": "忘记密码?",
|
||||
"noAccount": "没有账户?",
|
||||
"hasAccount": "已有账户?",
|
||||
"marketingConsent": "我同意偶尔接收 Memento 的近况和优惠(可选)。账户必要邮件仍会发送。",
|
||||
"signInToAccount": "登录您的账户",
|
||||
"createAccount": "创建您的账户",
|
||||
"rememberMe": "记住我",
|
||||
@@ -1031,6 +1044,11 @@
|
||||
"about": "关于",
|
||||
"version": "版本",
|
||||
"settingsSaved": "设置已保存",
|
||||
"marketingTitle": "近况与优惠",
|
||||
"marketingDescription": "偶尔接收 Memento 的近况和优惠。可选,并与账户、账单、密码邮件分开。",
|
||||
"marketingLegal": "您可以随时停止。已经交给发送服务的活动无法撤回。",
|
||||
"marketingBlocked": "此地址无法接收公告(投诉或永久投递失败)。",
|
||||
"settingsSaveFailed": "无法保存此选择。",
|
||||
"cardSizeMode": "笔记大小",
|
||||
"cardSizeModeDescription": "选择可变大小或统一大小",
|
||||
"selectCardSizeMode": "选择显示模式",
|
||||
@@ -1527,7 +1545,42 @@
|
||||
"agents": "代理",
|
||||
"settings": "设置",
|
||||
"billing": "计费与配额",
|
||||
"published": "已发布页面"
|
||||
"published": "已发布页面",
|
||||
"marketing": "营销",
|
||||
"stats": "统计"
|
||||
},
|
||||
"marketing": {
|
||||
"title": "营销",
|
||||
"description": "准备活动、选择收件人并附上优惠。发送尚未启用。",
|
||||
"emptyTitle": "还没有活动",
|
||||
"emptyBody": "有活动后,会显示在此表中,包含受众、优惠和状态。本页不会发送邮件。",
|
||||
"createCampaign": "创建活动",
|
||||
"createDisabledHint": "此版本尚未启用活动发送。",
|
||||
"columns": {
|
||||
"name": "内部名称",
|
||||
"goal": "目的",
|
||||
"audience": "收件人",
|
||||
"language": "语言",
|
||||
"offer": "优惠",
|
||||
"status": "状态",
|
||||
"scheduledAt": "计划日期",
|
||||
"accepted": "已被发送服务接受",
|
||||
"delivered": "已送达",
|
||||
"paidConversions": "付费转化"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"title": "统计",
|
||||
"description": "收入、订阅、产品使用与活动结果。只有来自可靠来源的数字才会显示。",
|
||||
"unavailable": "尚不可用",
|
||||
"unavailableBody": "此部分尚未接入统计来源。不会显示零,因为零表示“什么都没发生”。",
|
||||
"lastSyncUnknown": "上次更新:未知",
|
||||
"sectionRevenue": "收入",
|
||||
"sectionSubscriptions": "订阅",
|
||||
"sectionUsage": "使用",
|
||||
"sectionCampaigns": "活动",
|
||||
"periodLabel": "时间范围",
|
||||
"timezoneNote": "有统计后,日期将使用管理员时区。"
|
||||
},
|
||||
"metrics": {
|
||||
"vsLastPeriod": "与上一周期相比"
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
-- Additive marketing consent and suppression. No existing user is opted in.
|
||||
|
||||
CREATE TABLE "MarketingPreference" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"emailNormalized" TEXT NOT NULL,
|
||||
"optedIn" BOOLEAN NOT NULL DEFAULT false,
|
||||
"preferredLanguage" TEXT,
|
||||
"consentVersion" TEXT,
|
||||
"consentText" TEXT,
|
||||
"source" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "MarketingPreference_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "MarketingPreference_userId_key" ON "MarketingPreference"("userId");
|
||||
CREATE INDEX "MarketingPreference_emailNormalized_idx" ON "MarketingPreference"("emailNormalized");
|
||||
CREATE INDEX "MarketingPreference_optedIn_idx" ON "MarketingPreference"("optedIn");
|
||||
|
||||
ALTER TABLE "MarketingPreference" ADD CONSTRAINT "MarketingPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
CREATE TABLE "MarketingPreferenceEvent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"preferenceId" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"emailNormalized" TEXT NOT NULL,
|
||||
"optedIn" BOOLEAN NOT NULL,
|
||||
"source" TEXT NOT NULL,
|
||||
"consentVersion" TEXT,
|
||||
"consentText" TEXT,
|
||||
"ip" TEXT,
|
||||
"userAgent" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "MarketingPreferenceEvent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "MarketingPreferenceEvent_preferenceId_idx" ON "MarketingPreferenceEvent"("preferenceId");
|
||||
CREATE INDEX "MarketingPreferenceEvent_userId_createdAt_idx" ON "MarketingPreferenceEvent"("userId", "createdAt");
|
||||
CREATE INDEX "MarketingPreferenceEvent_emailNormalized_createdAt_idx" ON "MarketingPreferenceEvent"("emailNormalized", "createdAt");
|
||||
|
||||
ALTER TABLE "MarketingPreferenceEvent" ADD CONSTRAINT "MarketingPreferenceEvent_preferenceId_fkey" FOREIGN KEY ("preferenceId") REFERENCES "MarketingPreference"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
CREATE TABLE "EmailSuppression" (
|
||||
"id" TEXT NOT NULL,
|
||||
"emailNormalized" TEXT NOT NULL,
|
||||
"reason" TEXT NOT NULL,
|
||||
"source" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "EmailSuppression_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "EmailSuppression_emailNormalized_key" ON "EmailSuppression"("emailNormalized");
|
||||
CREATE INDEX "EmailSuppression_reason_idx" ON "EmailSuppression"("reason");
|
||||
CREATE INDEX "EmailSuppression_userId_idx" ON "EmailSuppression"("userId");
|
||||
|
||||
ALTER TABLE "EmailSuppression" ADD CONSTRAINT "EmailSuppression_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -54,6 +54,8 @@ model User {
|
||||
usageLogs UsageLog[]
|
||||
apiKeys UserAPIKey[]
|
||||
aiConsentLogs AiConsentLog[]
|
||||
marketingPreference MarketingPreference?
|
||||
emailSuppressions EmailSuppression[]
|
||||
noteClusters NoteCluster[]
|
||||
bridgeNotes BridgeNote[]
|
||||
bridgeSuggestions BridgeSuggestion[]
|
||||
@@ -1124,3 +1126,55 @@ model ErrorLog {
|
||||
@@index([createdAt])
|
||||
@@index([resolved])
|
||||
}
|
||||
|
||||
/// Explicit marketing announcements opt-in. Missing row = not opted in.
|
||||
model MarketingPreference {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique
|
||||
emailNormalized String
|
||||
optedIn Boolean @default(false)
|
||||
preferredLanguage String?
|
||||
consentVersion String?
|
||||
consentText String? @db.Text
|
||||
source String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
events MarketingPreferenceEvent[]
|
||||
|
||||
@@index([emailNormalized])
|
||||
@@index([optedIn])
|
||||
}
|
||||
|
||||
model MarketingPreferenceEvent {
|
||||
id String @id @default(cuid())
|
||||
preferenceId String
|
||||
userId String?
|
||||
emailNormalized String
|
||||
optedIn Boolean
|
||||
source String
|
||||
consentVersion String?
|
||||
consentText String? @db.Text
|
||||
ip String?
|
||||
userAgent String?
|
||||
createdAt DateTime @default(now())
|
||||
preference MarketingPreference @relation(fields: [preferenceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([preferenceId])
|
||||
@@index([userId, createdAt])
|
||||
@@index([emailNormalized, createdAt])
|
||||
}
|
||||
|
||||
/// Blocks campaign mail. Kept after account deletion (userId set null).
|
||||
model EmailSuppression {
|
||||
id String @id @default(cuid())
|
||||
emailNormalized String @unique
|
||||
reason String
|
||||
source String
|
||||
userId String?
|
||||
createdAt DateTime @default(now())
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([reason])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user