feat(referrals): add referral attribution and daily login rewards
CI / check (push) Successful in 4m25s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m54s

- Track referral attribution at registration via ?ref code with
  same-IP and duplicate-pair guards
- Add daily login rewards with streak tracking, claim flow and
  sendCurrency payout backed by RCON with DB fallback
- Add admin pages for referral settings and the daily reward schedule
- Add migration 0033 with tables, seed schedule, settings and ACL grants
- Add admin.referrals.* and admin.dailyrewards.* permission slugs
- Localize new copy in en, nl and it
This commit is contained in:
openhands committed 2026-09-20 12:29:01 +02:00
1 parent 463bc2cb47
commit c3ff497050
30 files changed
+2028 -11

No files matched your search

+83 -1
View File
@@ -1050,7 +1050,8 @@
"usernameRequired": "Username is required.",
"emailRequired": "Email is required.",
"passwordRequired": "Password is required.",
"passwordsDontMatch": "Passwords do not match."
"passwordsDontMatch": "Passwords do not match.",
"invitedBy": "Invited by {username}"
},
"forgot": {
"title": "Reset password",
@@ -1143,6 +1144,8 @@
"transactions": "Transactions",
"vouchers": "Vouchers",
"subscriptions": "Subscriptions",
"referrals": "Referrals",
"dailyRewards": "Daily rewards",
"rareValues": "Rare values",
"badges": "Badges",
"achievements": "Achievements",
@@ -3197,6 +3200,64 @@
"cancel": "Cancel"
}
},
"referrals": {
"title": "Referrals",
"subtitle": "Invitation system",
"referralsToday": "Referrals today",
"referralsTotal": "Total referrals",
"invitedTotal": "Invitations counted",
"claims": "Rewards claimed",
"settings": "Reward settings",
"needed": "Referrals needed",
"rewardAmount": "Reward amount",
"rewardCurrency": "Reward currency",
"blockSameIp": "Reject referrals sharing the inviter's current IP",
"save": "Save settings",
"saving": "Saving…",
"saved": "Referral settings saved.",
"saveError": "Referral settings could not be saved.",
"recent": "Recent referrals",
"unavailable": "Referral data is temporarily unavailable.",
"empty": "No referrals yet.",
"inviter": "Inviter",
"invitee": "Invited user",
"ip": "IP",
"date": "Date"
},
"dailyRewards": {
"title": "Daily login rewards",
"subtitle": "Daily login reward schedule",
"enabled": "Feature status",
"yes": "Enabled",
"no": "Disabled",
"scheduleDays": "Schedule days",
"claimsToday": "Claims today",
"claimsTotal": "Total claims",
"status": "Feature status",
"statusHint": "When disabled, the reward card is hidden and new claims are rejected.",
"disable": "Disable",
"enable": "Enable",
"schedule": "Reward schedule",
"day": "Day {day}",
"reward": "Reward",
"amount": "Amount",
"actions": "Actions",
"addDay": "Day",
"add": "Add day",
"save": "Save",
"saving": "Saving…",
"saved": "Reward schedule saved.",
"cancel": "Cancel",
"edit": "Edit",
"delete": "Delete",
"created": "Reward day added.",
"updated": "Reward day updated.",
"deleted": "Reward day removed.",
"saveError": "Reward day could not be saved.",
"deleteError": "Reward day could not be removed.",
"confirmDeleteTitle": "Remove reward day",
"confirmDeleteBody": "This reward day will no longer be granted. Past claims stay recorded."
},
"subscriptions": {
"title": "Subscriptions",
"subtitle": "Active recurring subscriptions",
@@ -4056,6 +4117,8 @@
"vouchers": "Vouchers",
"subscriptions": "Subscriptions",
"calendar": "Calendar",
"referrals": "Referrals",
"dailyRewards": "Daily rewards",
"history": "History",
"moderationTab": "Moderation",
"monitoring": "Monitoring",
@@ -6277,6 +6340,7 @@
"login": "Sign in",
"never": "Never",
"claimed": "Reward claimed successfully!",
"dailyClaimed": "Daily reward claimed — see you tomorrow!",
"welcome": "Your space on {hotel}",
"noMotto": "No motto yet",
"registered": "Registered: {date}",
@@ -6329,6 +6393,23 @@
"ratelimit": "Too many attempts. Wait a moment and try again.",
"error": "The reward could not be claimed. Please try again."
},
"dailyErrors": {
"already_claimed": "You already claimed today's daily reward.",
"disabled": "Daily rewards are currently disabled.",
"bad_config": "The daily reward schedule is not configured correctly. Contact the staff.",
"ratelimit": "Too many attempts. Wait a moment and try again.",
"error": "The daily reward could not be claimed. Please try again."
},
"dailyRewardTitle": "Daily rewards",
"dailyRewardHint": "Log in every day and claim your reward — streaks grow until the big days.",
"dailyRewardDay": "Day {day}",
"dailyRewardStreak": "{count, plural, one {Streak: # day} other {Streak: # days}}",
"dailyRewardNextClaim": "Next claim: {amount} {currency}",
"dailyRewardTomorrow": "Tomorrow: {amount} {currency}",
"dailyRewardClaim": "Claim daily reward",
"dailyRewardClaimedToday": "Claimed today — come back tomorrow for the next reward.",
"dailyRewardDisabled": "Daily rewards are currently disabled. You can still explore the hotel.",
"dailyRewardUnavailable": "The daily reward schedule could not be loaded. Please try again.",
"messagesCaughtUp": "No unread messages",
"messagesUnavailable": "Open your inbox to check",
"eventTitle": "On the calendar",
@@ -6345,6 +6426,7 @@
"friends": "{count, plural, one {# friend request to review} other {# friend requests to review}}",
"event": "Your next registered event: {title} · {date}",
"reward": "Your referral reward is ready to claim",
"dailyReward": "Your daily reward is ready to claim",
"empty": "You're all caught up. No actions waiting.",
"unavailable": "Some actions could not be checked. Available actions are shown above.",
"retry": "Try again",
+23
View File
@@ -1107,6 +1107,8 @@
"transactions": "Transazioni",
"vouchers": "Buoni",
"subscriptions": "Abbonamenti",
"referrals": "Inviti",
"dailyRewards": "Ricompense giornaliere",
"rareValues": "Valori rari",
"badges": "Distintivi",
"achievements": "Trofei",
@@ -4388,6 +4390,8 @@
"vouchers": "Voucher",
"subscriptions": "Abbonamenti",
"calendar": "Calendario",
"referrals": "Inviti",
"dailyRewards": "Ricompense giornaliere",
"history": "Cronologia",
"moderationTab": "Moderazione",
"monitoring": "Monitoraggio",
@@ -6282,6 +6286,7 @@
"login": "Accedi",
"never": "Mai",
"claimed": "Premio riscattato!",
"dailyClaimed": "Ricompensa giornaliera riscattata — torna domani!",
"welcome": "Il tuo spazio su {hotel}",
"noMotto": "Nessun motto",
"registered": "Iscritto dal {date}",
@@ -6334,6 +6339,13 @@
"ratelimit": "Troppi tentativi. Attendi un momento e riprova.",
"error": "Non è stato possibile riscattare il premio. Riprova."
},
"dailyErrors": {
"already_claimed": "Hai già riscattato la ricompensa giornaliera di oggi.",
"disabled": "Le ricompense giornaliere sono attualmente disattivate.",
"bad_config": "Il programma delle ricompense giornaliere non è configurato correttamente. Contatta lo staff.",
"ratelimit": "Troppi tentativi. Attendi un momento e riprova.",
"error": "Non è stato possibile riscattare la ricompensa giornaliera. Riprova."
},
"messagesCaughtUp": "Nessun messaggio da leggere",
"messagesUnavailable": "Apri la posta per controllare",
"eventTitle": "In programma",
@@ -6344,12 +6356,23 @@
"eventDetails": "Scopri l’evento",
"noUpcomingEvents": "Non sono ancora stati annunciati nuovi eventi.",
"roomsUnavailable": "Le stanze non sono al momento disponibili. Apri la community per riprovare.",
"dailyRewardTitle": "Ricompense giornaliere",
"dailyRewardHint": "Accedi ogni giorno e riscatta la ricompensa: la serie cresce fino ai giorni speciali.",
"dailyRewardDay": "Giorno {day}",
"dailyRewardStreak": "{count, plural, one {Serie: # giorno} other {Serie: # giorni}}",
"dailyRewardNextClaim": "Prossimo ritiro: {amount} {currency}",
"dailyRewardTomorrow": "Domani: {amount} {currency}",
"dailyRewardClaim": "Riscuoti la ricompensa giornaliera",
"dailyRewardClaimedToday": "Riscattata oggi — torna domani per la prossima ricompensa.",
"dailyRewardDisabled": "Le ricompense giornaliere sono attualmente disattivate. Puoi comunque esplorare l’hotel.",
"dailyRewardUnavailable": "Non è stato possibile caricare il programma delle ricompense giornaliere. Riprova.",
"tasks": {
"title": "Da fare",
"support": "{count, plural, one {# ticket con risposta del supporto} other {# ticket con risposte del supporto}}",
"friends": "{count, plural, one {# richiesta di amicizia da controllare} other {# richieste di amicizia da controllare}}",
"event": "Il prossimo evento a cui sei iscritto: {title} · {date}",
"reward": "Il premio per gli inviti è pronto da ritirare",
"dailyReward": "La tua ricompensa giornaliera è pronta da ritirare",
"empty": "Tutto a posto. Nessuna azione in attesa.",
"unavailable": "Non è stato possibile verificare alcune azioni. Quelle disponibili sono mostrate sopra.",
"retry": "Riprova",
+83 -1
View File
@@ -1050,6 +1050,7 @@
"emailRequired": "E-mail is verplicht.",
"passwordRequired": "Wachtwoord is verplicht.",
"passwordsDontMatch": "Wachtwoorden komen niet overeen.",
"invitedBy": "Uitgenodigd door {username}",
"accepted": "Accepted & agreed",
"showPassword": "Show",
"hidePassword": "Hide",
@@ -1140,6 +1141,8 @@
"transactions": "Transacties",
"vouchers": "Vouchers",
"subscriptions": "Abonnementen",
"referrals": "Uitnodigingen",
"dailyRewards": "Dagelijkse beloningen",
"rareValues": "Zeldzame waarden",
"badges": "Badges",
"achievements": "Prestaties",
@@ -3061,6 +3064,64 @@
"updated": "Voucher updated",
"updateError": "Could not update voucher"
},
"referrals": {
"title": "Uitnodigingen",
"subtitle": "Uitnodigingssysteem",
"referralsToday": "Uitnodigingen vandaag",
"referralsTotal": "Totaal uitnodigingen",
"invitedTotal": "Getelde uitnodigingen",
"claims": "Beloningen geclaimd",
"settings": "Beloningsinstellingen",
"needed": "Benodigde uitnodigingen",
"rewardAmount": "Beloningshoeveelheid",
"rewardCurrency": "Beloningsvaluta",
"blockSameIp": "Uitnodigingen weigeren die het huidige IP van de uitnodiger delen",
"save": "Instellingen opslaan",
"saving": "Opslaan…",
"saved": "Uitnodigingsinstellingen opgeslagen.",
"saveError": "Uitnodigingsinstellingen konden niet worden opgeslagen.",
"recent": "Recente uitnodigingen",
"unavailable": "Uitnodigingsgegevens zijn tijdelijk niet beschikbaar.",
"empty": "Nog geen uitnodigingen.",
"inviter": "Uitnodiger",
"invitee": "Uitgenodigde gebruiker",
"ip": "IP",
"date": "Datum"
},
"dailyRewards": {
"title": "Dagelijkse beloningen",
"subtitle": "Planning dagelijkse beloningen",
"enabled": "Functiestatus",
"yes": "Ingeschakeld",
"no": "Uitgeschakeld",
"scheduleDays": "Planningsdagen",
"claimsToday": "Claims vandaag",
"claimsTotal": "Totaal claims",
"status": "Functiestatus",
"statusHint": "Wanneer uitgeschakeld, wordt de beloningskaart verborgen en worden nieuwe claims geweigerd.",
"disable": "Uitschakelen",
"enable": "Inschakelen",
"schedule": "Beloningsplanning",
"day": "Dag {day}",
"reward": "Beloning",
"amount": "Hoeveelheid",
"actions": "Acties",
"addDay": "Dag",
"add": "Dag toevoegen",
"save": "Opslaan",
"saving": "Opslaan…",
"saved": "Beloningsplanning opgeslagen.",
"cancel": "Annuleren",
"edit": "Bewerken",
"delete": "Verwijderen",
"created": "Beloningsdag toegevoegd.",
"updated": "Beloningsdag bijgewerkt.",
"deleted": "Beloningsdag verwijderd.",
"saveError": "Beloningsdag kon niet worden opgeslagen.",
"deleteError": "Beloningsdag kon niet worden verwijderd.",
"confirmDeleteTitle": "Beloningsdag verwijderen",
"confirmDeleteBody": "Deze beloningsdag wordt niet langer toegekend. Eerdere claims blijven vastgelegd."
},
"subscriptions": {
"title": "Abonnementen",
"subtitle": "Actieve terugkerende abonnementen",
@@ -4688,6 +4749,8 @@
"vouchers": "Vouchers",
"subscriptions": "Abonnementen",
"calendar": "Kalender",
"referrals": "Uitnodigingen",
"dailyRewards": "Dagelijkse beloningen",
"history": "Geschiedenis",
"moderationTab": "Moderatie",
"monitoring": "Bewaking",
@@ -6172,6 +6235,7 @@
"friends": "{count, plural, one {# vriendschapsverzoek om te bekijken} other {# vriendschapsverzoeken om te bekijken}}",
"event": "Je volgende aangemelde evenement: {title} · {date}",
"reward": "Je beloning voor uitnodigingen staat klaar",
"dailyReward": "Je dagelijkse beloning staat klaar",
"empty": "Alles is bijgewerkt. Er zijn geen acties nodig.",
"unavailable": "Sommige acties konden niet worden gecontroleerd. Beschikbare acties staan hierboven.",
"retry": "Opnieuw proberen",
@@ -6183,6 +6247,7 @@
"login": "Sign in",
"never": "Never",
"claimed": "Reward claimed successfully!",
"dailyClaimed": "Dagelijkse beloning geclaimd — tot morgen!",
"welcome": "Your space on {hotel}",
"noMotto": "No motto yet",
"registered": "Registered: {date}",
@@ -6234,7 +6299,24 @@
"bad_config": "The reward is not configured correctly. Contact the staff.",
"ratelimit": "Too many attempts. Wait a moment and try again.",
"error": "The reward could not be claimed. Please try again."
}
},
"dailyErrors": {
"already_claimed": "Je hebt de dagelijkse beloning van vandaag al geclaimd.",
"disabled": "Dagelijkse beloningen zijn momenteel uitgeschakeld.",
"bad_config": "De planning voor dagelijkse beloningen is niet correct ingesteld. Neem contact op met het personeel.",
"ratelimit": "Te veel pogingen. Wacht even en probeer het opnieuw.",
"error": "De dagelijkse beloning kon niet worden geclaimd. Probeer het opnieuw."
},
"dailyRewardTitle": "Dagelijkse beloningen",
"dailyRewardHint": "Log elke dag in en claim je beloning — je streak groeit tot de grote dagen.",
"dailyRewardDay": "Dag {day}",
"dailyRewardStreak": "{count, plural, one {Streak: # dag} other {Streak: # dagen}}",
"dailyRewardNextClaim": "Volgende claim: {amount} {currency}",
"dailyRewardTomorrow": "Morgen: {amount} {currency}",
"dailyRewardClaim": "Dagelijkse beloning claimen",
"dailyRewardClaimedToday": "Vandaag geclaimd — kom morgen terug voor de volgende beloning.",
"dailyRewardDisabled": "Dagelijkse beloningen zijn momenteel uitgeschakeld. Je kunt nog steeds het hotel verkennen.",
"dailyRewardUnavailable": "De planning voor dagelijkse beloningen kon niet worden geladen. Probeer het opnieuw."
},
"search": {
"title": "Zoeken",