From cc99542fc0e9ed3b6d2ca2bbb05328e00b4d8c01 Mon Sep 17 00:00:00 2001 From: Jonny Luca <320529100+JonnyLuca@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:23:22 +0000 Subject: [PATCH 01/55] Add referral programme and promo-code client. --- .github/workflows/handbook-build-check.yaml | 8 +- .maestro/handbook/11-dashboard.yaml | 6 + .maestro/handbook/12-settings.yaml | 10 + .../handbook/20-settings-legal-documents.yaml | 17 + android/app/build.gradle | 6 +- android/app/proguard-rules.pro | 5 +- android/app/src/debug/AndroidManifest.xml | 28 + android/app/src/main/AndroidManifest.xml | 46 + .../kotlin/swiss/dfx/realunit/MainActivity.kt | 61 +- android/app/src/profile/AndroidManifest.xml | 25 + android/gradle.properties | 4 + .../gradle/wrapper/gradle-wrapper.properties | 2 +- assets/languages/strings_de.arb | 49 + assets/languages/strings_en.arb | 49 + assets/legal/referral_terms_de.md | 47 + assets/legal/referral_terms_en.md | 47 + docs/handbook/README.md | 13 +- docs/handbook/de/index.html | 453 ++++++ docs/referral-programme.md | 548 +++++++ docs/screens.md | 22 +- ios/Runner.xcodeproj/project.pbxproj | 4 +- ios/Runner/Runner.entitlements | 5 + ios/Runner/RunnerDebug.entitlements | 16 + lib/models/transaction.dart | 23 +- lib/packages/io/install_referrer_adapter.dart | 30 + lib/packages/io/install_referrer_port.dart | 10 + lib/packages/io/normalize_referral_code.dart | 809 ++++++++++ lib/packages/io/parse_install_referrer.dart | 100 ++ .../repository/transaction_repository.dart | 114 +- .../dto/referral_bind_result_dto.dart | 88 + .../dto/referral_code_lookup_dto.dart | 85 + .../dto/referral_created_invite_dto.dart | 48 + .../referral/dto/referral_invite_dto.dart | 102 ++ .../referral/dto/referral_payout_dto.dart | 59 + .../referral/dto/referral_summary_dto.dart | 68 + .../referral/dto/referral_terms_dto.dart | 31 + .../dfx/models/referral/locale_text.dart | 8 + .../models/referral/referral_json_list.dart | 230 +++ .../dfx/models/referral/referral_kind.dart | 25 + .../dfx/real_unit_referral_service.dart | 278 ++++ .../service/dfx/referral_lookup_status.dart | 38 + .../service/referral_payout_merger.dart | 54 + .../service/transaction_history_service.dart | 177 +- .../storage/dfx_transaction_storage.dart | 17 + lib/packages/storage/transaction_storage.dart | 19 +- lib/screens/dashboard/dashboard_page.dart | 2 + .../dashboard/widgets/transaction_row.dart | 110 ++ .../kyc_registration_step_cubit.dart | 3 +- .../kyc_registration_step_state.dart | 6 +- .../registration/kyc_registration_page.dart | 35 +- .../stash_resolved_referral_code.dart | 11 + .../steps/kyc_registration_referral_step.dart | 135 ++ .../widgets/referral_code_field.dart | 612 +++++++ .../referral/cubit/referral_cubit.dart | 241 +++ .../cubit/referral_eligibility_cubit.dart | 63 + .../cubit/referral_eligibility_state.dart | 35 + .../referral/cubit/referral_state.dart | 139 ++ lib/screens/referral/format_frozen_chf.dart | 38 + lib/screens/referral/frozen_chf_label.dart | 30 + lib/screens/referral/load_referral_terms.dart | 30 + .../referral/open_referral_create.dart | 35 + lib/screens/referral/open_referral_terms.dart | 12 + .../referral/referral_create_page.dart | 387 +++++ .../referral/referral_error_message.dart | 104 ++ lib/screens/referral/referral_limits.dart | 24 + .../referral/referral_overview_page.dart | 406 +++++ lib/screens/referral/referral_page.dart | 139 ++ lib/screens/referral/referral_share_text.dart | 34 + lib/screens/referral/referral_terms_page.dart | 338 ++++ .../referral/share_referral_invite.dart | 35 + .../widgets/referral_copy_invite_button.dart | 112 ++ .../widgets/referral_eligibility_resume.dart | 116 ++ .../referral/widgets/referral_entry_card.dart | 62 + .../widgets/referral_loading_status.dart | 33 + .../widgets/referral_share_invite_button.dart | 107 ++ lib/screens/settings/settings_page.dart | 300 ++-- .../settings_legal_documents_page.dart | 10 + .../transaction_history_download_button.dart | 9 +- .../widgets/transaction_history_row.dart | 113 +- lib/setup/di.dart | 11 + lib/setup/routing/boot_navigation.dart | 7 + .../routing/capture_install_referrer.dart | 42 + lib/setup/routing/referral_bind.dart | 303 ++++ lib/setup/routing/referral_pending_code.dart | 73 + lib/setup/routing/router_config.dart | 23 + lib/setup/routing/routes/app_link_entry.dart | 228 ++- lib/setup/routing/routes/legal_routes.dart | 1 + lib/setup/routing/routes/settings_routes.dart | 2 + lib/widgets/buttons/app_filled_button.dart | 12 +- lib/widgets/form/labeled_text_field.dart | 42 +- pubspec.lock | 16 + pubspec.yaml | 1 + scripts/assemble-handbook-legal.py | 21 +- scripts/assemble-handbook-screenshots.sh | 19 +- scripts/build-legal-downloads.sh | 8 +- scripts/run-handbook-flows.sh | 6 +- .../dashboard/dashboard_golden_test.dart | 4 + .../dashboard_states_golden_test.dart | 56 + .../dashboard_referral_entry_and_payout.png | Bin 0 -> 63880 bytes ...kyc_registration_referral_step_default.png | Bin 0 -> 25695 bytes .../kyc_registration_referral_step_promo.png | Bin 0 -> 76279 bytes ..._registration_referral_step_recognized.png | Bin 0 -> 34443 bytes .../kyc/kyc_registration_golden_test.dart | 2 + ..._registration_page_states_golden_test.dart | 16 +- ...egistration_referral_step_golden_test.dart | 103 ++ .../referral_create_page_invite_created.png | Bin 0 -> 29679 bytes .../macos/referral_create_page_name_entry.png | Bin 0 -> 18082 bytes .../macos/referral_entry_card_eligible.png | Bin 0 -> 11636 bytes .../macos/referral_gate_not_eligible.png | Bin 0 -> 16817 bytes .../macos/referral_overview_page_default.png | Bin 0 -> 52650 bytes ...eferral_payout_transaction_row_default.png | Bin 0 -> 15484 bytes .../macos/referral_terms_page_default.png | Bin 0 -> 59999 bytes .../macos/referral_terms_page_readonly.png | Bin 0 -> 49357 bytes .../referral_overview_golden_test.dart | 85 + .../referral_surfaces_golden_test.dart | 252 +++ .../macos/settings_page_referral_eligible.png | Bin 0 -> 48336 bytes .../settings/settings_golden_test.dart | 34 + ...gs_legal_documents_page_referral_terms.png | Bin 0 -> 58971 bytes .../settings_legal_documents_golden_test.dart | 16 + ...ansaction_history_page_referral_payout.png | Bin 0 -> 21740 bytes ...ransaction_history_states_golden_test.dart | 31 + test/helper/golden_mocks.dart | 3 + test/helper/pump_app.dart | 2 + test/helper/responsive_surface_catalog.dart | 20 +- test/models/transaction_test.dart | 39 +- .../config/legal_documents_config_test.dart | 1 + .../io/normalize_referral_code_test.dart | 955 +++++++++++ .../io/parse_install_referrer_test.dart | 242 +++ .../transaction_repository_test.dart | 334 +++- .../models/referral/referral_dtos_test.dart | 920 +++++++++++ .../referral/referral_json_list_test.dart | 274 ++++ .../registration_user_type_test.dart | 1 + .../dfx/real_unit_referral_service_test.dart | 678 ++++++++ .../dfx/referral_lookup_status_test.dart | 61 + .../service/referral_payout_merger_test.dart | 220 +++ ...transaction_history_service_sync_test.dart | 472 +++++- .../storage/dfx_transaction_storage_test.dart | 9 + .../widgets/payment_action_button_test.dart | 1 + .../referral_payout_transaction_row_test.dart | 100 ++ .../kyc/steps/kyc_email_page_test.dart | 2 + .../kyc/steps/kyc_registration_page_test.dart | 93 +- .../kyc_registration_step_cubit_test.dart | 22 +- .../stash_resolved_referral_code_test.dart | 23 + .../kyc_registration_referral_step_test.dart | 443 +++++ .../widgets/referral_code_field_test.dart | 1364 ++++++++++++++++ .../referral/format_frozen_chf_test.dart | 45 + .../referral/load_referral_terms_test.dart | 81 + .../referral/open_referral_create_test.dart | 208 +++ .../referral/open_referral_terms_test.dart | 46 + .../referral_copy_invite_button_test.dart | 319 ++++ .../referral/referral_create_page_test.dart | 1081 +++++++++++++ .../screens/referral/referral_cubit_test.dart | 920 +++++++++++ .../referral_eligibility_cubit_test.dart | 236 +++ .../referral/referral_entry_card_test.dart | 347 ++++ .../referral/referral_error_message_test.dart | 241 +++ .../referral/referral_limits_test.dart | 20 + .../referral/referral_overview_page_test.dart | 1430 +++++++++++++++++ test/screens/referral/referral_page_test.dart | 354 ++++ .../referral_responsive_matrix_test.dart | 235 +++ .../referral_share_invite_button_test.dart | 231 +++ .../referral/referral_share_text_test.dart | 112 ++ .../referral/referral_terms_page_test.dart | 579 +++++++ .../referral/share_referral_invite_test.dart | 67 + ...sell_bank_account_selection_page_test.dart | 1 + .../widgets/sell_executed_sheet_test.dart | 1 + .../settings_referral_entry_test.dart | 111 ++ .../settings_contact_page_test.dart | 2 + .../settings_legal_documents_page_test.dart | 52 +- .../settings_security_page_test.dart | 2 + .../support_email_capture_page_test.dart | 5 + .../support_chat_message_bubble_test.dart | 1 + ...nsaction_history_download_button_test.dart | 160 ++ ...transaction_history_referral_row_test.dart | 175 ++ test/setup/routing/app_link_entry_test.dart | 649 ++++++-- .../capture_install_referrer_test.dart | 89 + test/setup/routing/pay_route_test.dart | 1 + test/setup/routing/pay_send_routes_test.dart | 1 + test/setup/routing/referral_bind_test.dart | 843 ++++++++++ .../routing/referral_pending_code_test.dart | 66 + test/widgets/buttons/app_buttons_test.dart | 35 +- test/widgets/form/form_fields_test.dart | 81 + 181 files changed, 23810 insertions(+), 460 deletions(-) create mode 100644 assets/legal/referral_terms_de.md create mode 100644 assets/legal/referral_terms_en.md create mode 100644 docs/referral-programme.md create mode 100644 ios/Runner/RunnerDebug.entitlements create mode 100644 lib/packages/io/install_referrer_adapter.dart create mode 100644 lib/packages/io/install_referrer_port.dart create mode 100644 lib/packages/io/normalize_referral_code.dart create mode 100644 lib/packages/io/parse_install_referrer.dart create mode 100644 lib/packages/service/dfx/models/referral/dto/referral_bind_result_dto.dart create mode 100644 lib/packages/service/dfx/models/referral/dto/referral_code_lookup_dto.dart create mode 100644 lib/packages/service/dfx/models/referral/dto/referral_created_invite_dto.dart create mode 100644 lib/packages/service/dfx/models/referral/dto/referral_invite_dto.dart create mode 100644 lib/packages/service/dfx/models/referral/dto/referral_payout_dto.dart create mode 100644 lib/packages/service/dfx/models/referral/dto/referral_summary_dto.dart create mode 100644 lib/packages/service/dfx/models/referral/dto/referral_terms_dto.dart create mode 100644 lib/packages/service/dfx/models/referral/locale_text.dart create mode 100644 lib/packages/service/dfx/models/referral/referral_json_list.dart create mode 100644 lib/packages/service/dfx/models/referral/referral_kind.dart create mode 100644 lib/packages/service/dfx/real_unit_referral_service.dart create mode 100644 lib/packages/service/dfx/referral_lookup_status.dart create mode 100644 lib/packages/service/referral_payout_merger.dart create mode 100644 lib/screens/kyc/steps/registration/stash_resolved_referral_code.dart create mode 100644 lib/screens/kyc/steps/registration/steps/kyc_registration_referral_step.dart create mode 100644 lib/screens/kyc/steps/registration/widgets/referral_code_field.dart create mode 100644 lib/screens/referral/cubit/referral_cubit.dart create mode 100644 lib/screens/referral/cubit/referral_eligibility_cubit.dart create mode 100644 lib/screens/referral/cubit/referral_eligibility_state.dart create mode 100644 lib/screens/referral/cubit/referral_state.dart create mode 100644 lib/screens/referral/format_frozen_chf.dart create mode 100644 lib/screens/referral/frozen_chf_label.dart create mode 100644 lib/screens/referral/load_referral_terms.dart create mode 100644 lib/screens/referral/open_referral_create.dart create mode 100644 lib/screens/referral/open_referral_terms.dart create mode 100644 lib/screens/referral/referral_create_page.dart create mode 100644 lib/screens/referral/referral_error_message.dart create mode 100644 lib/screens/referral/referral_limits.dart create mode 100644 lib/screens/referral/referral_overview_page.dart create mode 100644 lib/screens/referral/referral_page.dart create mode 100644 lib/screens/referral/referral_share_text.dart create mode 100644 lib/screens/referral/referral_terms_page.dart create mode 100644 lib/screens/referral/share_referral_invite.dart create mode 100644 lib/screens/referral/widgets/referral_copy_invite_button.dart create mode 100644 lib/screens/referral/widgets/referral_eligibility_resume.dart create mode 100644 lib/screens/referral/widgets/referral_entry_card.dart create mode 100644 lib/screens/referral/widgets/referral_loading_status.dart create mode 100644 lib/screens/referral/widgets/referral_share_invite_button.dart create mode 100644 lib/setup/routing/capture_install_referrer.dart create mode 100644 lib/setup/routing/referral_bind.dart create mode 100644 lib/setup/routing/referral_pending_code.dart create mode 100644 test/goldens/screens/dashboard/goldens/macos/dashboard_referral_entry_and_payout.png create mode 100644 test/goldens/screens/kyc/goldens/macos/kyc_registration_referral_step_default.png create mode 100644 test/goldens/screens/kyc/goldens/macos/kyc_registration_referral_step_promo.png create mode 100644 test/goldens/screens/kyc/goldens/macos/kyc_registration_referral_step_recognized.png create mode 100644 test/goldens/screens/kyc/kyc_registration_referral_step_golden_test.dart create mode 100644 test/goldens/screens/referral/goldens/macos/referral_create_page_invite_created.png create mode 100644 test/goldens/screens/referral/goldens/macos/referral_create_page_name_entry.png create mode 100644 test/goldens/screens/referral/goldens/macos/referral_entry_card_eligible.png create mode 100644 test/goldens/screens/referral/goldens/macos/referral_gate_not_eligible.png create mode 100644 test/goldens/screens/referral/goldens/macos/referral_overview_page_default.png create mode 100644 test/goldens/screens/referral/goldens/macos/referral_payout_transaction_row_default.png create mode 100644 test/goldens/screens/referral/goldens/macos/referral_terms_page_default.png create mode 100644 test/goldens/screens/referral/goldens/macos/referral_terms_page_readonly.png create mode 100644 test/goldens/screens/referral/referral_overview_golden_test.dart create mode 100644 test/goldens/screens/referral/referral_surfaces_golden_test.dart create mode 100644 test/goldens/screens/settings/goldens/macos/settings_page_referral_eligible.png create mode 100644 test/goldens/screens/settings_legal_documents/goldens/macos/settings_legal_documents_page_referral_terms.png create mode 100644 test/goldens/screens/transaction_history/goldens/macos/transaction_history_page_referral_payout.png create mode 100644 test/packages/io/normalize_referral_code_test.dart create mode 100644 test/packages/io/parse_install_referrer_test.dart create mode 100644 test/packages/service/dfx/models/referral/referral_dtos_test.dart create mode 100644 test/packages/service/dfx/models/referral/referral_json_list_test.dart create mode 100644 test/packages/service/dfx/real_unit_referral_service_test.dart create mode 100644 test/packages/service/dfx/referral_lookup_status_test.dart create mode 100644 test/packages/service/referral_payout_merger_test.dart create mode 100644 test/screens/dashboard/widgets/referral_payout_transaction_row_test.dart create mode 100644 test/screens/kyc/steps/registration/stash_resolved_referral_code_test.dart create mode 100644 test/screens/kyc/steps/registration/steps/kyc_registration_referral_step_test.dart create mode 100644 test/screens/kyc/steps/registration/widgets/referral_code_field_test.dart create mode 100644 test/screens/referral/format_frozen_chf_test.dart create mode 100644 test/screens/referral/load_referral_terms_test.dart create mode 100644 test/screens/referral/open_referral_create_test.dart create mode 100644 test/screens/referral/open_referral_terms_test.dart create mode 100644 test/screens/referral/referral_copy_invite_button_test.dart create mode 100644 test/screens/referral/referral_create_page_test.dart create mode 100644 test/screens/referral/referral_cubit_test.dart create mode 100644 test/screens/referral/referral_eligibility_cubit_test.dart create mode 100644 test/screens/referral/referral_entry_card_test.dart create mode 100644 test/screens/referral/referral_error_message_test.dart create mode 100644 test/screens/referral/referral_limits_test.dart create mode 100644 test/screens/referral/referral_overview_page_test.dart create mode 100644 test/screens/referral/referral_page_test.dart create mode 100644 test/screens/referral/referral_responsive_matrix_test.dart create mode 100644 test/screens/referral/referral_share_invite_button_test.dart create mode 100644 test/screens/referral/referral_share_text_test.dart create mode 100644 test/screens/referral/referral_terms_page_test.dart create mode 100644 test/screens/referral/share_referral_invite_test.dart create mode 100644 test/screens/settings/settings_referral_entry_test.dart create mode 100644 test/screens/transaction_history/transaction_history_download_button_test.dart create mode 100644 test/screens/transaction_history/transaction_history_referral_row_test.dart create mode 100644 test/setup/routing/capture_install_referrer_test.dart create mode 100644 test/setup/routing/referral_bind_test.dart create mode 100644 test/setup/routing/referral_pending_code_test.dart diff --git a/.github/workflows/handbook-build-check.yaml b/.github/workflows/handbook-build-check.yaml index 788b7a7e5..6ea8dcfd5 100644 --- a/.github/workflows/handbook-build-check.yaml +++ b/.github/workflows/handbook-build-check.yaml @@ -92,8 +92,8 @@ jobs: set -euo pipefail bash scripts/assemble-handbook-screenshots.sh /tmp/handbook-shots count=$(ls -1 /tmp/handbook-shots/*.png | wc -l | tr -d ' ') - if [ "$count" != "284" ]; then - echo "expected 284 screenshots, got $count" >&2 + if [ "$count" != "299" ]; then + echo "expected 299 screenshots, got $count" >&2 exit 1 fi @@ -173,11 +173,11 @@ jobs: exit 1 fi - # Screenshots dir must contain all 284 PNGs assembled from Goldens. + # Screenshots dir must contain all 299 PNGs assembled from Goldens. # Hit one of them through the auth gate to verify wiring end-to-end. # Mix of the original 01-61 range and the 62-269 batch (every Golden # baseline) so a regression in either half surfaces here. - for name in 01-welcome 11-dashboard 26-terms 35-dashboard-with-balance 46-buy-kyc-required 52-sell-unknown-error 53-buy-payment-details 61-kyc-registration-tax-tin-error 62-welcome-page-android 219-settings-security-page-default 268-phone-number-field-default 269-dashboard-insider-unlocked 270-handbook-persona-dca 275-handbook-persona-exit-1j; do + for name in 01-welcome 11-dashboard 26-terms 35-dashboard-with-balance 46-buy-kyc-required 52-sell-unknown-error 53-buy-payment-details 61-kyc-registration-tax-tin-error 62-welcome-page-android 219-settings-security-page-default 268-phone-number-field-default 269-dashboard-insider-unlocked 270-handbook-persona-dca 275-handbook-persona-exit-1j 276-referral-overview 282-referral-payout-row 283-referral-terms-readonly 287-kyc-registration-referral-recognized 288-transaction-history-referral-payout 289-dashboard-referral-entry-and-payout 290-kyc-registration-referral-promo; do code=$(curl -s -o /dev/null -w '%{http_code}' -u "${HANDBOOK_USER:-x}:${HANDBOOK_PASS:-x}" "http://127.0.0.1:8080/screenshots/${name}.png") # 200 (auth happens to match) or 401 (auth fails but file exists) # both prove the file is on disk. 404 means it was not assembled. diff --git a/.maestro/handbook/11-dashboard.yaml b/.maestro/handbook/11-dashboard.yaml index a24a2bd08..85b04f5b7 100644 --- a/.maestro/handbook/11-dashboard.yaml +++ b/.maestro/handbook/11-dashboard.yaml @@ -35,3 +35,9 @@ appId: swiss.realunit.app - extendedWaitUntil: visible: 'RealUnit kaufen' timeout: 30000 +# Fresh handbook wallet is not a verified shareholder with 70 REALU +# (TB Ziff. 2, analog Verkaufen). The dashboard card must stay off this +# screen; 289-dashboard-referral-entry-and-payout is golden-only. +- extendedWaitUntil: + notVisible: 'Erhalte 20 REALU pro Weiterempfehlung' + timeout: 10000 diff --git a/.maestro/handbook/12-settings.yaml b/.maestro/handbook/12-settings.yaml index 2491e4850..f33254f54 100644 --- a/.maestro/handbook/12-settings.yaml +++ b/.maestro/handbook/12-settings.yaml @@ -24,3 +24,13 @@ appId: swiss.realunit.app - extendedWaitUntil: visible: 'Einstellungen' timeout: 30000 +# Same gate as the dashboard card. Wait for a settings tile that does not +# depend on the referral API, then assert Empfehlungen is still absent so +# a late summary 200 cannot have opened the tile on a fresh wallet. +- extendedWaitUntil: + visible: + text: '.*Rechtsdokumente.*' + timeout: 15000 +- extendedWaitUntil: + notVisible: 'Empfehlungen' + timeout: 8000 diff --git a/.maestro/handbook/20-settings-legal-documents.yaml b/.maestro/handbook/20-settings-legal-documents.yaml index d75298723..e11b27811 100644 --- a/.maestro/handbook/20-settings-legal-documents.yaml +++ b/.maestro/handbook/20-settings-legal-documents.yaml @@ -39,3 +39,20 @@ appId: swiss.realunit.app visible: text: '.*Dokumente der Aktionariat AG.*' timeout: 30000 +# Last tile is the 14.08 Teilnahmebedingungen (Ziff. 2–11). On a phone it can +# sit below the fold, so scroll it into view, then restore Aktionariat so +# flow 21 can tap that entry. +- scrollUntilVisible: + element: + text: '.*Teilnahmebedingungen Referral-Programm.*' + direction: DOWN + timeout: 15000 +- extendedWaitUntil: + visible: + text: '.*Teilnahmebedingungen Referral-Programm.*' + timeout: 10000 +- scrollUntilVisible: + element: + text: '.*Dokumente der Aktionariat AG.*' + direction: UP + timeout: 15000 diff --git a/android/app/build.gradle b/android/app/build.gradle index 0a1b23b87..d6e33b5cb 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -14,7 +14,7 @@ if (keystorePropertiesFile.exists()) { android { namespace = "swiss.realunit.app" compileSdk = 36 - ndkVersion = "29.0.13113456 rc1" + ndkVersion = "29.0.13113456" compileOptions { sourceCompatibility = JavaVersion.VERSION_1_8 @@ -64,3 +64,7 @@ android { flutter { source = "../.." } + +dependencies { + implementation "com.android.installreferrer:installreferrer:2.2" +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 53960b220..d6b437275 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -18,4 +18,7 @@ # SumSub SDK general keep rules -keep class com.sumsub.** { *; } --dontwarn com.sumsub.** +-dontwarn com.sumsub.** + +# Play Install Referrer (invite code across a fresh Android install) +-keep class com.android.installreferrer.** { *; } diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 399f6981d..2aa4793da 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -4,4 +4,32 @@ to allow setting breakpoints, to provide hot reload, etc. --> + + + + + + + + + + + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a07818b21..842709029 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -54,6 +54,52 @@ + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/swiss/dfx/realunit/MainActivity.kt b/android/app/src/main/kotlin/swiss/dfx/realunit/MainActivity.kt index 5f60b31ab..6d0a79578 100644 --- a/android/app/src/main/kotlin/swiss/dfx/realunit/MainActivity.kt +++ b/android/app/src/main/kotlin/swiss/dfx/realunit/MainActivity.kt @@ -1,5 +1,64 @@ package swiss.realunit.app +import android.os.Handler +import android.os.Looper +import com.android.installreferrer.api.InstallReferrerClient +import com.android.installreferrer.api.InstallReferrerStateListener import io.flutter.embedding.android.FlutterFragmentActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel -class MainActivity: FlutterFragmentActivity() +class MainActivity : FlutterFragmentActivity() { + private val installReferrerChannel = "swiss.realunit.app/install_referrer" + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, installReferrerChannel) + .setMethodCallHandler { call, result -> + if (call.method != "readInstallReferrer") { + result.notImplemented() + return@setMethodCallHandler + } + readInstallReferrer(result) + } + } + + private fun readInstallReferrer(result: MethodChannel.Result) { + val client = InstallReferrerClient.newBuilder(this).build() + val main = Handler(Looper.getMainLooper()) + var replied = false + + fun reply(value: String?) { + if (replied) return + replied = true + result.success(value) + try { + client.endConnection() + } catch (_: Exception) { + } + } + + main.postDelayed({ reply(null) }, 3000) + try { + client.startConnection( + object : InstallReferrerStateListener { + override fun onInstallReferrerSetupFinished(responseCode: Int) { + if (responseCode != InstallReferrerClient.InstallReferrerResponse.OK) { + reply(null) + return + } + try { + reply(client.installReferrer.installReferrer) + } catch (_: Exception) { + reply(null) + } + } + + override fun onInstallReferrerServiceDisconnected() {} + }, + ) + } catch (_: Exception) { + reply(null) + } + } +} diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml index 399f6981d..edc280f12 100644 --- a/android/app/src/profile/AndroidManifest.xml +++ b/android/app/src/profile/AndroidManifest.xml @@ -4,4 +4,29 @@ to allow setting breakpoints, to provide hot reload, etc. --> + + + + + + + + + + + + + diff --git a/android/gradle.properties b/android/gradle.properties index 259717082..4147ba381 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,3 +1,7 @@ org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true +# This builtInKotlin flag was added automatically by Flutter migrator +android.builtInKotlin=false +# This newDsl flag was added automatically by Flutter migrator +android.newDsl=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 02767eb1c..e4ef43fb9 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/assets/languages/strings_de.arb b/assets/languages/strings_de.arb index 7ed95d199..ca288fa77 100644 --- a/assets/languages/strings_de.arb +++ b/assets/languages/strings_de.arb @@ -267,6 +267,55 @@ "receiver": "Empfänger", "recoveryWords": "Wiederherstellungs-Wörter", "recoveryWordsInvalid": "Wiederherstellungs-Wörter ungültig", + "referralAlreadyBound": "Du hast bereits einen Einladungs- oder Promo-Code eingelöst.", + "referralAlreadyRegistered": "Diese Einladung gilt nur für neue Kundinnen und Kunden.", + "referralChfAtSharePrice": "≈ ${chf} zum ${label}", + "referralCodeChecking": "Code wird geprüft…", + "referralCodeDescription": "Optional. Über den Einladungslink wird der Code automatisch übernommen; hier kannst du ihn auch manuell eingeben.", + "referralCodeHeading": "Hast du einen Empfehlungscode?", + "referralCodeHint": "Einladungs- oder Promo-Code", + "referralCodeInvalid": "Dieser Code ist ungültig oder abgelaufen.", + "referralCodeInvalidTitle": "Link ungültig oder abgelaufen", + "referralCodeOptional": "Einladungs- oder Promo-Code (optional)", + "referralCodeSpent": "Dieser Einladungs- oder Promo-Code wurde bereits verwendet.", + "referralCodeSpentTitle": "Code bereits eingelöst", + "referralCodeUnavailable": "Wir konnten den Code gerade nicht prüfen. Bitte versuche es später erneut.", + "referralCopied": "Kopiert", + "referralCopyInviteLink": "Einladungslink kopieren", + "referralCreateInvite": "Einladungslink erstellen", + "referralCreateInviteDescription": "Gib den Vornamen der Person ein, die du einladen möchtest.", + "referralCreating": "Einladung wird erstellt…", + "referralGuestName": "Name der eingeladenen Person", + "referralGuestNameRequired": "Bitte den Vornamen der eingeladenen Person eingeben.", + "referralInviteRecognized": "Einladung von ${inviter} erkannt. ${inviter} erhält seine Prämie automatisch, sobald du verifiziert bist und deinen ersten Kauf abgeschlossen hast.", + "referralInvitesLoading": "Offene Einladungen werden geladen…", + "referralInviteUrlLabel": "Persönlicher Einladungslink", + "referralLoading": "Empfehlungen werden geladen…", + "referralNotEligible": "Das Empfehlungsprogramm steht verifizierten Aktionären mit dem erforderlichen Bestand zur Verfügung.", + "referralOpenInvitesExpire": "Offene Einladungen verfallen nach 3 Monaten.", + "referralOverviewHint": "20 REALU pro Weiterempfehlung, sobald der erste Kauf erfolgreich abgewickelt ist.", + "referralOverviewTitle": "Deine Empfehlungen", + "referralPayout": "Empfehlungsprämie", + "referralPayoutChf": "CHF ${chf} zum Zeitpunkt der Gutschrift", + "referralPromoTitle": "Aktion", + "referralQuarterCap": "In diesem Quartal sind keine weiteren Prämien möglich.", + "referrals": "Empfehlungen", + "referralSelfReferral": "Du kannst deine eigene Einladung nicht einlösen.", + "referralShareInviteLink": "Einladungslink versenden", + "referralSharePrice": "Aktienkurs", + "referralShareText": "Hey ${guestName}, ${hostName} lädt dich ein zu RealUnit: ${url}", + "referralShareTextNoName": "${hostName} lädt dich ein zu RealUnit: ${url}", + "referralsSubtitle": "Erhalte 20 REALU pro Weiterempfehlung", + "referralStatusCredited": "Gutgeschrieben", + "referralStatusOpen": "Offen", + "referralTermsAccepting": "Teilnahmebedingungen werden akzeptiert…", + "referralTermsCheckbox": "Ich habe die Teilnahmebedingungen gelesen und akzeptiert", + "referralTermsLoading": "Teilnahmebedingungen werden geladen…", + "referralTermsRequired": "Bitte zuerst die Teilnahmebedingungen akzeptieren.", + "referralTermsTitle": "Teilnahmebedingungen Referral-Programm", + "referralTotalReceived": "Insgesamt erhalten", + "referralYourInvite": "Deine Einladung", + "referralYourInviteFor": "Deine Einladung für ${guestName}", "refresh": "Aktualisieren", "registerAccountType": "Kontotyp", "registerCitizenship": "Staatsangehörigkeit", diff --git a/assets/languages/strings_en.arb b/assets/languages/strings_en.arb index 44db6efe9..fe8e144c2 100644 --- a/assets/languages/strings_en.arb +++ b/assets/languages/strings_en.arb @@ -267,6 +267,55 @@ "receiver": "Receiver", "recoveryWords": "recovery words", "recoveryWordsInvalid": "Recovery words invalid", + "referralAlreadyBound": "You have already applied an invite or promo code.", + "referralAlreadyRegistered": "This invitation is only for new customers.", + "referralChfAtSharePrice": "≈ ${chf} at ${label}", + "referralCodeChecking": "Checking code…", + "referralCodeDescription": "Optional. The invite link fills this automatically; you can also enter the code here.", + "referralCodeHeading": "Do you have a referral code?", + "referralCodeHint": "Invite or promo code", + "referralCodeInvalid": "This code is invalid or has expired.", + "referralCodeInvalidTitle": "Link invalid or expired", + "referralCodeOptional": "Invite or promo code (optional)", + "referralCodeSpent": "This invite or promo code has already been used.", + "referralCodeSpentTitle": "Code already used", + "referralCodeUnavailable": "We could not look up this code right now. Please try again later.", + "referralCopied": "Copied", + "referralCopyInviteLink": "Copy invite link", + "referralCreateInvite": "Create invite link", + "referralCreateInviteDescription": "Enter the first name of the person you want to invite.", + "referralCreating": "Creating invitation…", + "referralGuestName": "Name of the invited person", + "referralGuestNameRequired": "Please enter the first name of the person you are inviting.", + "referralInviteRecognized": "Invitation from ${inviter} recognised. ${inviter} receives the prize automatically once you are verified and have completed your first purchase.", + "referralInvitesLoading": "Loading open invitations…", + "referralInviteUrlLabel": "Personal invite link", + "referralLoading": "Loading referrals…", + "referralNotEligible": "The referral programme is available once you are a verified shareholder with the required holding.", + "referralOpenInvitesExpire": "Open invitations expire after 3 months.", + "referralOverviewHint": "20 REALU per referral once the first purchase completes successfully.", + "referralOverviewTitle": "Your referrals", + "referralPayout": "Referral prize", + "referralPayoutChf": "CHF ${chf} at credit", + "referralPromoTitle": "Promotion", + "referralQuarterCap": "No further prizes can be credited this quarter.", + "referrals": "Referrals", + "referralSelfReferral": "You cannot apply your own invitation.", + "referralShareInviteLink": "Share invite link", + "referralSharePrice": "Share price", + "referralShareText": "Hey ${guestName}, ${hostName} is inviting you to RealUnit: ${url}", + "referralShareTextNoName": "${hostName} is inviting you to RealUnit: ${url}", + "referralsSubtitle": "Receive 20 REALU per referral", + "referralStatusCredited": "Credited", + "referralStatusOpen": "Open", + "referralTermsAccepting": "Accepting terms of participation…", + "referralTermsCheckbox": "I have read and accepted the terms of participation", + "referralTermsLoading": "Loading terms of participation…", + "referralTermsRequired": "Please accept the terms of participation first.", + "referralTermsTitle": "Referral programme terms of participation", + "referralTotalReceived": "Total received", + "referralYourInvite": "Your invitation", + "referralYourInviteFor": "Your invitation for ${guestName}", "refresh": "Refresh", "registerAccountType": "Account type", "registerCitizenship": "Citizenship", diff --git a/assets/legal/referral_terms_de.md b/assets/legal/referral_terms_de.md new file mode 100644 index 000000000..5158e00d2 --- /dev/null +++ b/assets/legal/referral_terms_de.md @@ -0,0 +1,47 @@ +# Teilnahmebedingungen Referral-Programm RealUnit App + +*Stand: 14.08.2026* + +## 1. Veranstalterin + +RealUnit Schweiz AG, Schochenmühlestrasse 6, 6340 Baar. Das Referral-Programm wird in der RealUnit App angeboten. + +## 2. Teilnahmeberechtigung + +Teilnahmeberechtigt sind natürliche Personen, die in der RealUnit App registriert und verifiziert sind und mindestens 70 RealUnit-Aktientoken im eigenen Wallet halten. Der Mindestbestand muss im Zeitpunkt der Erstellung der Einladung und im Zeitpunkt der Qualifikation nach Ziff. 4 erfüllt sein. Von der Teilnahme ausgeschlossen sind Mitarbeitende und Organe der Veranstalterin. + +## 3. Ablauf + +Der Empfehler erstellt in der App eine Einladung und teilt den Einladungslink. Die eingeladene Person darf noch nicht in der App registriert sein. + +## 4. Prämie und Qualifikation + +Der Empfehler erhält 20 RealUnit-Aktientoken, sobald die eingeladene Person registriert und verifiziert ist und ihren ersten Kauf erfolgreich abgeschlossen hat. Die Prämie wird dem Wallet des Empfehlers gutgeschrieben und in der Transaktionshistorie mit Frankenwert ausgewiesen. Die Prämie ist Entgelt für die Vermittlung der eingeladenen Person. Ein Anspruch entsteht erst mit vollständiger Erfüllung dieser Voraussetzungen. + +## 5. Obergrenze + +Je Empfehler werden höchstens einhundert erfolgreiche Empfehlungen innerhalb eines Quartals prämiert. + +## 6. Steuern + +Die Prämie stellt beim Empfehler steuerbares Einkommen dar und ist von ihm zu deklarieren. Die Veranstalterin weist Anzahl, Frankenwert und Datum der Gutschrift aus, erteilt jedoch keine Steuerauskunft. + +## 7. Datenschutz + +Für die Teilnahme bearbeitet die Veranstalterin den Einladungscode, die Zuordnung zwischen Empfehler und eingeladener Person sowie den Qualifikationsstatus. Verantwortlich ist die Veranstalterin; die technische Bearbeitung erfolgt durch ihren Dienstleister als Auftragsbearbeiter. Angaben zur Registrierung, Verifizierung oder zu Käufen der eingeladenen Person werden dem Empfehler nicht bekanntgegeben; angezeigt wird nur der Status des eigenen Prämienanspruchs. Einladungen, die nicht zu einer Qualifikation führen, werden nach drei Monaten gelöscht. Im Übrigen gilt die Datenschutzerklärung der RealUnit App. + +## 8. Missbrauch + +Nicht prämiert werden Empfehlungen, die auf Selbstempfehlung, mehrfache Identitäten, koordinierte Anwerbung oder sonstige Umgehung dieser Bedingungen zurückgehen. Die Veranstalterin kann Prämien vor der Ausschüttung zurückstellen und manuell prüfen sowie bereits ausgeschüttete Prämien bei nachträglich festgestelltem Missbrauch zurückfordern. + +## 9. Werbung + +Für die Empfehlung ist ausschliesslich der in der App bereitgestellte Einladungslink zu verwenden. Eigene Aussagen über die Veranstalterin, ihre Aktien oder deren Wertentwicklung sind nicht Bestandteil des Programms und dürfen nicht im Namen der Veranstalterin gemacht werden. + +## 10. Dauer, Änderung und Beendigung + +Das Programm läuft auf unbestimmte Dauer und kann von der Veranstalterin jederzeit geändert oder ohne Vorankündigung beendet werden. Bereits entstandene Prämienansprüche bleiben unberührt; für ausgesprochene, aber noch nicht qualifizierte Einladungen besteht kein Anspruch. + +## 11. Anwendbares Recht + +Es gilt Schweizer Recht. Gerichtsstand ist Baar. diff --git a/assets/legal/referral_terms_en.md b/assets/legal/referral_terms_en.md new file mode 100644 index 000000000..21fdfce8d --- /dev/null +++ b/assets/legal/referral_terms_en.md @@ -0,0 +1,47 @@ +# Terms of Participation — Referral Programme RealUnit App + +*As of: 14 August 2026* + +## 1. Organiser + +RealUnit Schweiz AG, Schochenmühlestrasse 6, 6340 Baar. The referral programme is offered in the RealUnit app. + +## 2. Eligibility + +Eligible are natural persons who are registered and verified in the RealUnit app and who hold at least 70 RealUnit share tokens in their own wallet. The minimum holding must be met at the time the invitation is created and at the time of qualification under section 4. Employees and officers of the organiser are excluded from participation. + +## 3. Process + +The referrer creates an invitation in the app and shares the invitation link. The invited person must not already be registered in the app. + +## 4. Reward and qualification + +The referrer receives 20 RealUnit share tokens once the invited person is registered and verified and has successfully completed their first purchase. The reward is credited to the referrer’s wallet and shown in the transaction history with its Swiss-franc value. The reward is remuneration for introducing the invited person. A claim arises only once these conditions are fully met. + +## 5. Cap + +At most one hundred successful referrals per referrer are remunerated within a calendar quarter. + +## 6. Taxes + +The reward constitutes taxable income of the referrer and must be declared by them. The organiser states the quantity, Swiss-franc value and date of the credit, but does not provide tax advice. + +## 7. Data protection + +For participation the organiser processes the invitation code, the assignment between referrer and invited person, and the qualification status. The organiser is the controller; technical processing is carried out by its service provider as processor. Information about the invited person’s registration, verification or purchases is not disclosed to the referrer; only the status of the referrer’s own reward claim is shown. Invitations that do not lead to qualification are deleted after three months. In all other respects the RealUnit app privacy policy applies. + +## 8. Abuse + +Referrals based on self-referral, multiple identities, coordinated solicitation or any other circumvention of these terms are not remunerated. The organiser may hold rewards before payout and review them manually, and may claw back rewards already paid if abuse is established later. + +## 9. Advertising + +Only the invitation link provided in the app may be used for referrals. Statements of one’s own about the organiser, its shares or their performance are not part of the programme and may not be made in the organiser’s name. + +## 10. Duration, changes and termination + +The programme runs for an indefinite period and may be changed or ended by the organiser at any time without prior notice. Reward claims that have already arisen remain unaffected; there is no claim for invitations that have been issued but have not yet qualified. + +## 11. Applicable law + +Swiss law applies. Place of jurisdiction is Baar. diff --git a/docs/handbook/README.md b/docs/handbook/README.md index 6c7497e23..497d6b340 100644 --- a/docs/handbook/README.md +++ b/docs/handbook/README.md @@ -21,7 +21,7 @@ deployten Image (`handbook.realunit.app`). ## Screenshots regenerieren -Es gibt keinen separaten Regeneration-Schritt: Die 284 Handbook-Screenshots +Es gibt keinen separaten Regeneration-Schritt: Die 299 Handbook-Screenshots sind direkt die Golden-Baselines unter `test/goldens/` (gemappt in `scripts/assemble-handbook-screenshots.sh`). Eine UI-Änderung an einer der gemappten Pages produziert beim `flutter test test/goldens` einen Diff — @@ -111,13 +111,22 @@ auf `https://api.dfx.swiss/v1/country`; eine lokale HTML-Vorschau fällt auf die ## E-Mail Previews Die HTML-Vorschauen aller vom Backend an Endkunden versendeten Mails liegen -**nicht in diesem Repo**. Quelle ist `DFXswiss/api`: +**nicht in diesem Repo**. Quelle für KYC-, Kauf- und Verkaufs-Mails ist +`DFXswiss/api`: - Generator: `scripts/generate-realunit-previews.js` - Vorlage: `src/subdomains/supporting/notification/templates/realunit.hbs` - Übersetzungen: `src/shared/i18n/de/mail-realunit.json` (RealUnit-Texte) mit Fallback auf `src/shared/i18n/de/mail.json` (DFX-Defaults) +Die Bestätigungs-E-Mail der Empfehlungsprämie (Anzahl, Datum D.M.YYYY, fixierter +Frankenwert, TB Ziff. 6) kommt aus dem Nest-Drop-in +[JonnyLuca/dfx-referral-api](https://github.com/JonnyLuca/dfx-referral-api) +(`PrizeMailAdapter`, `GET /v1/realunit/referral/admin/emails`) — nicht aus +`realunit.hbs`. Der Mailer bekommt `html` / `htmlEn` zur Sendezeit aus dem +Klartext (ohne extra Persist-Spalte); die Überwachung bleibt der Klartext. +Der Live-Mount bleibt `DFXswiss/backend`. + Der Handbook-CI-Build (`.github/workflows/handbook.yaml`) checkt das api-Repo zur Build-Zeit aus, führt den Generator aus und kopiert das Ergebnis nach `docs/handbook/mails/`, bevor das Docker-Image gebaut wird. `docs/handbook/mails/` diff --git a/docs/handbook/de/index.html b/docs/handbook/de/index.html index d2f10836e..69127d54b 100644 --- a/docs/handbook/de/index.html +++ b/docs/handbook/de/index.html @@ -1143,6 +1143,9 @@
  • WWeb · realunit.app
  • +
  • + REmpfehlungen & Promo-Code +
  • ME-Mails
  • @@ -9151,6 +9154,423 @@

    404 — Seite nicht gefunden +
    + + + +

    + Verifizierte Aktionäre mit ausreichendem Bestand sehen auf dem Dashboard + die Karte Empfehlungen («Erhalte 20 REALU pro Weiterempfehlung») + und denselben Einstieg in den Einstellungen. Sichtbarkeit, Mindestbestand + und Auszahlung entscheidet die DFX API; die App rendert + eligible 1:1. Nach einem fehlgeschlagenen ersten + Summary-GET (unmounted, 503, Timeout) blendet die Karte aus; + solange die Route aktuell und die App im Vordergrund ist, wird + die Gate erneut geladen, ohne eine bereits sichtbare Karte zu + verstecken. Ein 200 mit eligible: false wird nicht + gepollt. +

    +

    + Einladung erstellen: Name der eingeladenen Person (eine Zeile, + Zeilenumbrüche werden zu Leerzeichen, höchstens 80 Zeichen; + Absenden räumt den Feldtext auf; Autofokus), Checkbox + Ich habe die Teilnahmebedingungen gelesen und akzeptiert + (Checkbox erst nach geladenem TB-Text; Button erst danach aktiv; + der TB-Text ist markierbar), persönlicher Link von der API. + Der Einladungslink ist immer + https://realunit.app/invite/… + (http://, //realunit.app und + www.realunit.app werden auf den Apex gefaltet; + ein API-Pfad /promo/… bleibt Promo). + Nach dem Erstellen und in der Übersicht erscheint der personalisierte + Share-Text unter Persönlicher Einladungslink; Kopieren und + Versenden schicken denselben Text. + Nach der Annahme bleiben die Teilnahmebedingungen über den + Übersichtstitel und Einstellungen → Rechtliche Dokumente + (letzter Eintrag) lesbar. Kopieren bestätigt mit + Kopiert für zwei Sekunden nur nach erfolgreichem Schreiben + in die Zwischenablage und liest den Share-Text vor; ein erneutes + Tippen kopiert wieder. + Versenden setzt Titel und Betreff Persönlicher Einladungslink + und verankert das iPad/Mac-Share-Popover am Button (sonst am + Bildschirm). Kopiert-Status fällt zurück, wenn der Share-Text + wechselt. Übersicht zeigt Anzahl offen / + gutgeschrieben (vorgelesen «3 Offen») sowie Summe REALU und Frankenwert; offene Einladungen + lassen sich erneut kopieren und versenden, ohne den Status der + eingeladenen Person. Offene Einladungen verfallen nach 3 Monaten. + Kachel-Beschriftung + Aktienkurs. Jede Gutschrift in der Historie zeigt ganze REALU, + Datum und den im Zeitpunkt der Gutschrift fixierten Frankenwert + (eine Vorlese-Zeile: Titel, Datum, CHF, Anzahl) — + kein späteres Nachziehen auf den aktuellen Kurs. +

    +

    + Promo-Code und Einladungscode teilen dasselbe optionale + Registrierungs-Schritt (Überspringen erlaubt) und denselben Deep Link. + Während der Prüfung zeigt das Feld Code wird geprüft…. + Nach der Prüfung erscheint bei einem Promo-Code der + Aktionstext in einem eigenen Fenster (nur Schließen, kein Tippen + daneben); bei einer Einladung die Erkennung + mit dem Namen des Empfehlers. Ein eingefügter + realunit.app/invite|promo/…-Link oder eine Share-Nachricht + wird auf den Code reduziert (Zero-Width- und Bidi-Zeichen aus + Messenger-Copy werden entfernt, auch im Play-Referrer und direkt + im Eingabefeld; Einfügen ignoriert eine Zwischenablage nur aus + Formatzeichen); das Feld hat Autofokus und fügt einen von der + Landing kopierten Code selbst ein, wenn es leer ist + (Deeplink-Stash geht vor). Weiter/Überspringen + sperren das Feld, bis sie durch sind, und brechen eine + ausstehende Code-Prüfung ab (Weiter wartet auf die Prüfung; + Überspringen setzt Weiter nicht auf Laden). Überspringen + verwirft einen getippten Code und lässt einen + Deeplink-Stash stehen. Ein abgelehnter Bind (4xx) zeigt + Link ungültig oder abgelaufen einmal in einem Dialog. 20 REALU als Promo-Zugabe nur beim ersten erfolgreichen + Kauf von mindestens N REALU (Vorgabe 200, durch die API gesetzt). + Einlösung und Kumulierungssperre sind Server-Pflicht. Universal Links: + https://realunit.app/invite/… und + /promo/… öffnen die App (auch auf www.realunit.app, + HTTP 200, kein 301). Nach Neuinstallation auf iOS den + Link erneut antippen oder den Code von der Landing-Seite + (App-Store-Tap kopiert ihn) bei der Registrierung einfügen. Android trägt den + Code als Play-Store-Install-Referrer, schon während die Landing + den Code prüft; ein ungültiger Lookup entfernt den Referrer. + Auf iOS führt der App-Store-Badge, auf Android Play. Die + In-App-CTA bleibt bis zum Lookup-Ende verborgen und gilt nur mobil. + Landing und Registrierung zeigen den Code in Grossbuchstaben + und höchstens 32 Zeichen (wie die API); ein angehängtes + Satzzeichen (`!`, `?`, `/`) fällt weg. Steckt + invite/ oder promo/ im Token, + bleibt nur der Code (wie die API). Unsichtbare + Steuerzeichen (ZWSP) und Vollbreite-Zeichen aus Messenger-URLs + entfernt die Landing-HTML schon in den Bytes, die Safari für + den Smart App Banner ausliest. Dasselbe gilt für Play/Ads- + utm_content=invite= und referrer auf + der Landing-URL, nicht nur für /invite/{code} + — auch ohne https:// (realunit.app/invite/…). + Facebook u=, Google q=, Outlook + url= und E-Mail link= auf der + Landing-URL ebenso, ein bloßes + u=hello oder eine fremde https://-URL + nicht. Ein Kampagnenname in utm_content verdeckt + keinen späteren Wrapper-Schlüssel, und eine Tracking-URL, die + nur realunit.app nennt, auch nicht. Einfügen und App + Links verwenden dieselben Schlüssel. Ein leeres oder fremdes + code= verdeckt kein späteres invite=. + Proofpoint URL Defense, Outlook Safe Links und kurze + ios-app://-Alternate-Links um eine + RealUnit-Landing werden entpackt. + Safari erkennt den Code und das Aktionsdatum nicht als Telefon- oder + Kalender-Link (format-detection + telephone=no, date=no; + x-apple-data-detectors="false" am Code und + Aktionstext, weil Lookup-JS den Text nachlädt). + Tab-Titel, Open-Graph, Twitter und die Standard-Beschreibung folgen + Laden / ungültig / nicht erreichbar; ein Pfad ohne Code gilt als + ungültig. Ungültige Überschrift und Text sind statusrot; + die Browser-Themenfarbe folgt (`#E02523` ungültig, sonst Markenblau). + og:locale:alternate nennt die andere Sprache (de_CH / en_GB). + Crawler lesen Smart-App-Banner app-argument, + og:image:alt / twitter:image:alt, + og:site_name (RealUnit), + Facebook App Links und Twitter App Cards aus den HTML-Bytes (ohne JS): + al:ios:url, al:android:url und + twitter:app:url:iphone / ipad / + googleplay sind + realunit-wallet://invite|promo/…, + al:android:class ist + swiss.realunit.app.MainActivity, + al:web:url die HTTPS-Landing, + twitter:app:country ist CH. + «Erneut versuchen» prüft den Code erneut ohne Seiten-Reload + (der Button wird vom Fehlertext beschrieben) und setzt den Fokus auf + «Code wird geprüft…»; nach dem Ergebnis auf die Ergebnis-Überschrift. + «Kopiert» erscheint nur, wenn Code oder Link wirklich in der + Zwischenablage liegt, und wird über den Status-Hinweis vorgelesen. +

    +

    + Automatisierte Oberflächen-Tests: Responsive-Matrix auf + Teilnahmebedingungen, Übersicht und Erstellseite, Cubit- und + API-Vertrags-Tests, Deeplink-Parser. Visual-Regression: Übersicht + (`276`), Registrierungs-Schritt (`277`), Dashboard-Karte (`278`), + Nicht-berechtigt (`279`), Einladung erstellt (`280`), + Teilnahmebedingungen (`281`), Prämie in der Historie (`282`), + TB nach Annahme (`283`), Namenseingabe (`284`), Einstellungen-Karte + (`285`), Rechtliche Dokumente letzter Eintrag (`286`), Einladung + erkannt (`287`), Historie mit Prämie (`288`), Dashboard-Einstieg + (`289`), Promo-Aktionstext (`290`). +

    +
    +
    + + + referral_overview_golden_test.dart +
    +
    + Empfehlungs-Übersicht mit Aktienkurs und offener Einladung +
    +
    + Kachel Insgesamt erhalten mit ganzen REALU, fixiertem CHF + und Beschriftung Aktienkurs; Zähler Offen / Gutgeschrieben; + offene Einladung mit Kopieren und Versenden. +
    +
    +
    +
    + + + kyc_registration_referral_step_golden_test.dart +
    +
    + Optionaler Einladungs- oder Promo-Code in der Registrierung +
    +
    + Optionaler Code-Schritt (Überspringen erlaubt) mit Autofokus, + Einfügen und Weiter. +
    +
    +
    +
    + + + referral_surfaces_golden_test.dart +
    +
    + Dashboard-Karte Empfehlungen +
    +
    + Karte Empfehlungen mit «Erhalte 20 REALU pro Weiterempfehlung», + nur wenn die API eligible setzt. +
    +
    +
    +
    + + + referral_surfaces_golden_test.dart +
    +
    + Empfehlungsprogramm nicht berechtigt +
    +
    + Gate für nicht berechtigte Nutzer, Schließen mit Tastaturfokus. +
    +
    +
    +
    + + + referral_surfaces_golden_test.dart +
    +
    + Persönlicher Einladungslink nach dem Erstellen +
    +
    + Nach dem Erstellen: personalisierter Share-Text, Kopieren und Versenden. +
    +
    +
    +
    + + + referral_surfaces_golden_test.dart +
    +
    + Teilnahmebedingungen mit Checkbox gelesen und akzeptiert +
    +
    + Teilnahmebedingungen als eigene Seite; Checkbox + «Ich habe die Teilnahmebedingungen gelesen und akzeptiert»; + «Einladungslink erstellen» erst danach aktiv. +
    +
    +
    +
    + + + referral_surfaces_golden_test.dart +
    +
    + Empfehlungsprämie in der Transaktionshistorie +
    +
    + Prämie mit ganzer REALU-Anzahl, Datum und Frankenwert, + fixiert im Zeitpunkt der Gutschrift. +
    +
    +
    +
    + + + referral_surfaces_golden_test.dart +
    +
    + Teilnahmebedingungen nach Annahme, ohne Checkbox +
    +
    + Nach der Annahme bleiben die Teilnahmebedingungen lesbar + (Übersichtstitel und Einstellungen → Rechtliche Dokumente), + ohne Checkbox und ohne «Einladungslink erstellen». +
    +
    +
    +
    + + + referral_surfaces_golden_test.dart +
    +
    + Namenseingabe für die persönliche Einladung +
    +
    + Einladung erstellen: Vorname der eingeladenen Person, Button + «Einladungslink erstellen». +
    +
    +
    +
    + + + settings_golden_test.dart +
    +
    + Einstellungen-Karte Empfehlungen für berechtigte Aktionäre +
    +
    + Einstieg «Empfehlungen» in den Einstellungen, nur wenn die API + eligible setzt. Kartentext «Erhalte 20 REALU pro + Weiterempfehlung». +
    +
    + +
    +
    + + + kyc_registration_referral_step_golden_test.dart +
    +
    + Einladung von Björn erkannt in der Registrierung +
    +
    + Entwurf 4: Code-Eingabe als Fallback; nach Prüfung «Einladung von + Björn erkannt». +
    +
    +
    +
    + + + transaction_history_states_golden_test.dart +
    +
    + Transaktionshistorie mit Empfehlungsprämie, Datum und fixiertem CHF +
    +
    + Historie: Prämie mit ganzer Anzahl (20 REALU), Datum und + Frankenwert, fixiert im Zeitpunkt der Gutschrift (TB Ziff. 6). +
    +
    +
    +
    + + + dashboard_states_golden_test.dart +
    +
    + Dashboard-Karte Empfehlungen und Prämie in den letzten Transaktionen +
    +
    + Entwurf 1: Einstieg «Empfehlungen» («Erhalte 20 REALU pro + Weiterempfehlung») und Prämie in den letzten Transaktionen. +
    +
    +
    +
    + + + kyc_registration_referral_step_golden_test.dart +
    +
    + Promo-Aktionstext in einem eigenen Fenster +
    +
    + Punkt 5: nach Prüfung erscheint der von RealUnit vorgegebene + Aktionstext in einem eigenen Fenster (nur Schließen). +
    +
    +
    + +
    +
    @@ -9176,6 +9596,10 @@

    ME-Mails

    jedem Handbook-Build automatisch frisch generiert und ins Image gepackt — Single Source of Truth ist das api-Repo, dieses Handbook spiegelt nur den aktuellen Stand. + Die Bestätigungs-E-Mail der Empfehlungsprämie (Anzahl, Datum, + fixierter Frankenwert) kommt aus dem Nest-Drop-in, nicht aus + realunit.hbs: der Mailer sendet HTML aus dem Klartext; + GET /admin/emails bleibt der Klartext.

    @@ -9662,6 +10086,35 @@

    Registrierungsvereinbarung

    +
    + + +
    diff --git a/docs/referral-programme.md b/docs/referral-programme.md new file mode 100644 index 000000000..42d46acda --- /dev/null +++ b/docs/referral-programme.md @@ -0,0 +1,548 @@ +# Referral programme and promo code + +Implementation spec for this repository and `realunit.app`. The DFX API is +the decision authority. This app is a rendering layer. + +## API contract (`api.dfx.swiss` / `dev.api.dfx.swiss`) + +Authenticated routes use the existing Bearer session. + +Live contract until `DFXswiss/backend` is writable (private): +[JonnyLuca/dfx-referral-api](https://github.com/JonnyLuca/dfx-referral-api) +(`GET`/`POST /v1/realunit/referral/*`, 70 REALU gate re-checked at credit, +quarterly cap 100, 3-month expiry, promo `redemptionCap` required, min-buy N +default 200, KYC + late bind, CORS for `realunit.app`, NestJS drop-in +`RealUnitReferralController`). Credit is evaluated on Aktionariat +**settlement** (whole REALU shares) and again when KYC Level 30 is +reached — not on `PUT /v1/realunit/buy/{id}/confirm` (payment +instructions only). On mount, `ReferralAuthHydrateInterceptor` rewrites `req.user.id` to the +JWT wallet address so a numeric RealUnitLegal user id cannot miss +holdings or prize send. + +Authenticated calls hydrate mail, firstName, live +`kyc.level` and Aktionariat `confirmedDate` from `GET /v2/user`, +`GET /v2/kyc` (`x-kyc-code` from `kyc.hash`, same as this app), +`GET /v2/user/profile` and `GET /v1/realunit/registration` with the same +Bearer this app already sends. First purchase (if the settlement hook +is not yet wired) is the earliest inbound mint from `0x0` on +`GET /v1/realunit/account/{address}/history`, paginated with `first=200` +and `after=pageInfo.endCursor`. A successful authenticated GET may +credit from that mint, and also when KYC Level 30 arrives on a wallet +that already has a recorded first purchase. The server persists before +the 200 so a retry cannot double-pay. A failed live holding lookup +does not credit from a stale snapshot (TB Ziff. 2). GET summary and +POST invites then return `503 { "code": "UNAVAILABLE" }` so this app +retries instead of showing «not eligible». Live `GET /v1/realunit/account` +404 `Account not found` is a known-zero holding (tile stays hidden), not +unknown. Live swagger still +has no `/v1/realunit/referral` paths; public lookup currently returns +NestJS `Cannot GET`. The app maps that body (and `503 UNAVAILABLE`) +to the unavailable retry copy, not Nest internals. `onAccountMerge` / +`mergeWallets` is idempotent so a DFX `register/wallet` retry after +the dropped key is gone does not 404. + +### `GET /v1/realunit/referral/summary` + +```json +{ + "eligible": true, + "termsAccepted": true, + "minHolding": 70, + "openCount": 1, + "creditedCount": 2, + "realuSum": 40, + "chfSum": 55.2, + "sharePriceLabel": "Aktienkurs", + "sharePrice": 1.38 +} +``` + +`eligible` is the dashboard/settings gate (KYC-verified, holding ≥ 70 +REALU, not an employee). The app must not recompute +shareholder status or the 70 REALU holding locally. +A failed first load hides the tile; returning to the foreground or to +this route (pop back from Settings) retries the summary without a +loading flash so a later mount can open it, and a blip does not hide a +tile that is already shown. While that GET failed (unmounted Nest +`Cannot GET`, 503, timeout) the gate is also polled so the tile can +open without backgrounding the app. Polling runs only while this +route is current and the app is in the foreground. A 200 with +`eligible: false` is not polled. +`sharePrice` is the live Aktienkurs (`GET /v1/realunit/price` `chf`); +the total tile uses `realuSum * sharePrice` (running value). `chfSum` +is the sum of frozen payout CHF for history. `sharePriceLabel` is the +API token `Aktienkurs` (never «aktueller NAV»). The tile localizes it +(DE «Aktienkurs», EN «Share price»). + +### `GET /v1/realunit/referral/terms` + +```json +{ + "version": "2026-08-14", + "markdown": "…", + "markdownEn": "…" +} +``` + +The app renders this 1:1. Bundled `assets/legal/referral_terms_*.md` is only +a fallback when the call fails. + +### `POST /v1/realunit/referral/terms/accept` + +Body: `{ "accepted": true }`. + +### `POST /v1/realunit/referral/invites` + +Body: `{ "guestName": "Alice" }`. Empty, whitespace-only, or +format-character-only names are **400** (`guestName required`). The +server applies the same folds as the app (ZWSP/bidi stripped, Unicode +spaces collapsed, cap 80). + +Response: + +```json +{ + "code": "AB12CD", + "url": "https://realunit.app/invite/AB12CD", + "guestName": "Alice", + "copyText": "Hey Alice, Björn lädt dich ein zu RealUnit: https://realunit.app/invite/AB12CD", + "copyTextEn": "Hey Alice, Björn invites you to RealUnit: https://realunit.app/invite/AB12CD", + "inviterName": "Björn" +} +``` + +The server generates code, URL, and share text. `inviterName` is the +Empfehler display name so a missing `copyText` still shares +«Hey Alice, Björn …» instead of «Hey Alice, RealUnit …». A later +`GET /v2/user/profile` firstName (or Kontozusammenführung) rewrites +open-invite `copyText` / `inviterName` so a first invite created +while the name was still the wallet address is not stuck as +«Hey Alice, tritt RealUnit bei». Wallet and numeric ids stay out of +the prize-mail greeting too; unsent mail waits for a real `mail` +address instead of sending to `0x…`. The prize confirmation is HTML at +send (`html` / `htmlEn` from the plaintext Anzahl / Datum / frozen CHF); +Überwachung `GET /admin/emails` stays the compact plaintext body. The app renders +`copyText` / `copyTextEn` 1:1 except `http://`, protocol-relative +`//realunit.app`, `www.realunit.app`, and scheme-less +`realunit.app/…` are folded onto +`https://realunit.app` (`dev.realunit.app` is left unchanged). A scheme-less or `//` invite/promo URL keeps +its path (`/promo/…` is not rewritten to `/invite/`). Empty EN falls +back to DE. Guest names are a single line (newlines/tabs become spaces) +and capped at 80 characters in the app. Submit writes the sanitized +name back into the field before POST. The name field autofocuses and +accepts given-name autofill. + +### `GET /v1/realunit/referral/invites` + +List of the current user's invites (bare array or `{ "invites": [...] }`). +Each row includes `copyText` / `copyTextEn` / `inviterName` so copy/share +on overview can name the Empfehler when the server omits share text. +The Empfehler list is **Open** or **Credited** only. Bound and Review +are folded to Open server-side so the Empfehler cannot see the invitee’s +registration or purchase progress (TB Ziff. 7). Admin relationships keep +the true status. `Deleted`, `Expired` and `Rejected` rows are omitted. +Counts of `Open` and `Credited` come from the summary. Open invites can be +copied and shared again (including a missing/blank +guestName as a nameless invite, and including a missing created +timestamp, and including a missing/blank status treated as Open). +If a backend still returns `Bound` or `Review`, the app treats them as +open so copy/share stay until Credited. An unknown non-terminal status +is treated as open so a new API spelling cannot hide copy/share; +credited/paid and deleted/expired/rejected are not open. Pending +Open/Bound/Review rows are **Deleted** after 90 days. +credited +names stay hidden — never registration, verification, or purchases of +the invited person. If this list call fails, the app still shows the +summary tiles (open/credited/total) and omits the copy/share rows on +first load; a later reload keeps previous rows. Retry is shown for a +list error even when those rows stay on screen, and when the summary +open count has no matching rows. List Retry is keyboard-focused when +no open-invite rows are on screen. Retry reloads only the list so a +summary outage cannot hide the counts. List Retry stays on the +list-error copy in the loading state so a second tap is ignored +(the rows are not replaced with only the loading spinner). List Retry is ignored while an +overview refresh is in flight; a slower list GET is discarded so it +cannot roll back counts after create. + +### `POST /v1/realunit/referral/bind` + +Body: `{ "code": "AB12CD" }`. Binds an invite or promo code. + +```json +{ + "kind": "Promo", + "campaignText": "…", + "campaignTextEn": "…", + "minBuyRealu": 200, + "validUntil": "2026-09-07T00:00:00Z", + "redemptionCap": 100 +} +``` + +Invite binds return `inviterName` / `inviteeName` (same fields as lookup) +so a late Universal-Link bind can show the registration recognition +copy. Promo `campaignText` is shown 1:1 in a dialog. + +`kind` is `Invite` or `Promo`. If `kind` is omitted, campaign/action text +without an inviter name is treated as promo so the confirmation dialog +still appears. The API rejects self-referral, double-bind, and promo+invite +stacking. Promo credit is only the first successful purchase of at least +`minBuyRealu` (default 200). A first buy below N creates no later claim. +`redemptionCap` is required — no unlimited option. + +### `GET /v1/realunit/referral/code/:code` (public) + +Landing payload for `realunit.app/invite/…` and `/promo/…`. +The server strips ZWSP/bidi, unwraps `/invite|promo/{code}` (including a +pasted `https://realunit.app/invite/…`), and upper-cases before lookup. +Empty after that fold is **400**. Bound, credited, rejected and +cap-spent codes are **410** `{ code: SPENT }` so the landing does not +greet with names after the invite is used. 90-day delete and disabled +promo are **410** `{ code: EXPIRED }`. Unknown is **404**. HTTP 400/404/409/410/422 mean the code is invalid or spent. 5xx, 401, 408 +and 429 are transport failures — the registration field must not show +«invalid», and the landing shows «unavailable». Persist failure is +`503 { "code": "UNAVAILABLE" }` (retry, not expired). A NestJS 404 whose +message starts with `Cannot GET` / `Cannot POST` means the route is not +mounted yet: treat it as unavailable and keep a stashed code for retry. + +### `GET /v1/realunit/referral/payouts` + +Bare array or `{ "payouts": [...] }`. Each row carries `amount` (whole REALU; +a fractional API value is truncated, never rounded up), +`created`, and `chfValue` frozen at credit. The app never recomputes that +CHF amount from the live share +price. The API returns **Settled** rows only (Offerte Punkt 4) with public +fields (`id`, `amount`, `chfValue`, `created`, `kind`, `status`, +`txHash`). `userId` and `inviteId` stay off this list. Pending +and failed payouts stay out of history until the transfer +is confirmed. The server persists the broadcast `txHash` while the +row is still Pending and confirms that receipt on retry, so a +restart does not send a second 20 REALU. `getPayouts` drops non-settled rows if a payload includes them. Missing +status is treated as Settled (this list is Settled-only). Duplicate payout rows (same id or tx hash) are shown once, including +when history sync writes a payload that repeats an id or hash casing. +A settled row with neither id nor tx hash is dropped so it cannot +collide as `referral-payout-0`. +Frozen CHF +is stored as two decimals (`246.50`) at merge time and when history +sync writes the row (amount is truncated, never rounded up). A locale-formatted +`chfValue` (`246,5`, `1'246.50`, `CHF 246.50`) still parses as a payout +row. + +## App surfaces + +- Dashboard card and settings entry only when `summary.eligible`. + A second eligibility load while the summary GET is in flight is + ignored, so a slower retry cannot hide the card after a later success. + A timed-out eligibility GET is not retried, so a hung summary cannot + occupy the dashboard for two 15s budgets. + Gate and create-invite not-eligible screens offer Close + (keyboard-focused). Gate, overview, and create-invite summary + failures put keyboard focus on Retry, as does a terms-markdown + load failure. A timed-out summary, create, or list GET shows the + unavailable copy, not the TimeoutException string. Retry on a summary + failure stays on the error copy in the loading state so a second tap + is ignored (the screen is not replaced with a blank spinner). Create-invite + Retry from needs-terms stays on that copy the same way. +- Terms page (markdown selectable, only http(s) links open in the in-app + browser; root-relative `/…` paths open as `https://realunit.ch/…`; + protocol-relative `//host/…` opens as https; mailto and other schemes stay in the markdown); the accepted-terms checkbox is shown only after the + markdown has loaded; a later load (language change or Retry) + discards an earlier in-flight result. A hung bundled TB asset is timed + out after 5s so Retry is shown. Retry stays on the load-failed copy in + the loading state so a second tap is ignored (the screen is not + replaced with a blank spinner). create-invite button after checkbox + «Ich habe die Teilnahmebedingungen gelesen und akzeptiert». + Accepting terms opens the name-entry screen. A second accept while + the POST is in flight is ignored. A failed accept + focuses Create so it can be retried. A second accept stays on that + error copy in the loading state so the page is not replaced with only + the accepting spinner. After create, the + personalised share text is shown under «Persönlicher Einladungslink» + for copy and share. Share is keyboard-focused. A failed create + POST focuses Create (the name field autofocus is cleared). A second + create stays on that error copy in the loading state so the form is + not replaced with only «Einladung wird erstellt…». Guest-name + paste collapses Unicode spaces (nbsp, em/thin/ideographic) to ASCII + spaces as they are typed and on submit so a messenger name stays one line. A second + submit while create is in flight is ignored. Copy confirms with «Kopiert» for two seconds + only after a successful clipboard write (announced only then, + including the share text). A failed clipboard write keeps «Einladungslink + kopieren» in the error state for two seconds and stays tappable. A + second tap while the clipboard write is in flight is ignored — Copy + is in the loading state and not tappable. A hung + write is treated as a failure after two seconds so copy is not stuck. + A second tap copies again and restarts the + two-second timer. A new share text clears the Kopiert state and does + not confirm or error a write that started on a previous invite. Share uses that label as the share-sheet + title and email subject, and anchors the iOS/Mac popover on the share + button (screen origin if the button has no size). A failed share + (throw or unavailable) keeps «Einladungslink versenden» in the error + state for two seconds and stays tappable. A second tap while the + sheet is open is ignored. If the share sheet never returns, resuming + the app clears loading so Versenden is tappable again. A new share text while the sheet is open + does not show that error on the new invite. Dismissing the sheet does not. Open invite rows are + keyed by invite id and code so two nameless rows with a missing id + cannot share a key, and copy/share state cannot jump to another guest. +- Overview: open / credited counts, total REALU (truncated to a whole + token, never rounded up), CHF, label «Aktienkurs» + (empty or «NAV» API labels fall back to the localized Aktienkurs copy). + Count tiles are announced as «3 Offen» / «2 Gutgeschrieben»; the total + tile is one name (REALU, frozen CHF, Aktienkurs). + Open invites show the personalised share text (API copyText 1:1, + otherwise the localised template) and can be copied and shared again, + including when the guest name is blank («Deine Einladung», share text + without «Hey ,»); + credited names stay hidden. Copy and share behave as after create. + With no open-invite rows and no list error, Create is keyboard-focused. + A second overview refresh while the summary GET is in flight is ignored, + so a slower failure after create cannot hide the tiles. + A second tap while create is already open is ignored. The create-route + lock is released when that screen pops, and overview refresh after + create is not awaited, so a hung summary GET cannot block Create. + The overview title and Settings → Legal documents (last tile) open + the Teilnahmebedingungen read-only (GET /terms 1:1, then bundled + 14.08) so Ziff. 2–11 stay reachable after the checkbox. +- Registration: dedicated optional step (skip allowed) with the same + field for invite and promo. A pasted `realunit.app/invite|promo/…` + URL (or the landing copy button) is reduced to the code before lookup + and bind, including a query-only origin URL (`https://realunit.app?invite=`), + a hash-only path (`/invite#CODE`, `invite#CODE`, `realunit-wallet://invite#CODE`, + `https://realunit.app/invite#CODE`, `https://realunit.app#CODE`, + `realunit.app#CODE`), including App Links that carry the code only in + the fragment, + a nested landing URL in the path (`/invite/https://realunit.app/invite/{code}`, + `intent://`, or `realunit-wallet://`), + `?code=` / `?invite=` wrapping a landing URL (including an App Link + `https://realunit.app/invite?code=https://realunit.app/invite/{code}`), + Facebook `l.php?u=` / Google `url?q=` wrappers around a landing URL, + WhatsApp/Telegram/SMS/mailto/Messenger/Threema/Signal/Viber/LINE + share-sheet schemes (`whatsapp://`, `tg://`, `sms:`, `smsto:`, + `mailto:`, `fb-messenger://`, `threema://`, `sgnl://`, `viber://`, + `line://`, `wa.me`, `t.me/share`) with a percent-encoded landing URL in + `text` / `body` / `url` / `link` / the LINE path, including share copy + around the URL, + Chrome Android `intent://send?text=…#Intent;scheme=whatsapp;end` and + `#Intent;S.text=` / `S.browser_fallback_url=` extras, + Outlook Safe Links `url=`, Proofpoint URL Defense v2 (`-3A` / `_`) + and v3 (`__/https://realunit.app/…__`), + key-less wrappers (`href.li/?https://realunit.app/invite/{code}`), + path-nested landing URLs (`https://example.com/r/https://realunit.app/invite/{code}`, + Yahoo `RU=https://realunit.app/invite/{code}`), + and AMP/CDN paths (`https://www.google.com/amp/s/realunit.app/invite/{code}`), + `app-argument` and ads/Play `utm_content` / + `referrer` wrapping `invite=` or a landing URL, also when that URL + is embedded in a share message without a path code, including Chrome + `intent://…/invite?code=` and android-app / ios-app + `…/invite?code=` alternate links. Format characters that messengers inject around a copied + code or URL (zero-width, LRM/RLM, bidi) are stripped so lookup is not + sent a tainted token. Extracted codes are uppercased, stripped of + messenger zero-width/fullwidth characters and trailing sentence punct + (`!`, `?`, `/`, …), and capped at 32 like the API `sanitizeReferralCode` + fold, including a nested `invite|promo/{code}` inside a pasted token, + as are typographic quotes wrapping a copied URL + (`“…”`, `«…»`), wrapping parentheses, and a trailing `)` from a + markdown link, a trailing `"` / `'` from an HTML `href`, or HTML + entities (`"…"`, `https://`, `/`, `:`, `/`), + JSON-escaped slashes (`https:\\/\\/…`) and `\u003a` / `\u002f`, + fullwidth `:` / `/` and fullwidth digits/letters from a mobile keyboard, + Word/Pages fraction slash `⁄`, division slash `∕`, and ratio colon `∶`, + CJK ideographic full stop `。` as the domain dot (`realunit。app`), + Word/PDF typesetting spaces (nbsp, thin space, narrow nbsp) in a URL + or code, + and line breaks that email clients insert + in a wrapped URL (`/invite/\nCODE`) or spaces inserted after `/` or + `://` (`https://realunit.app/ invite/CODE`), or `>` quote prefixes on + wrapped reply lines, or a backslash before the line break + (`/invite/\\\nCODE`), a hyphen before the line break from PDF wrapping + (`/in-\\nvite/CODE`) or a Unicode hyphen (`/in‐\\nvite/CODE`), + a quoted-printable soft break (`/inv=\\nite/CODE`) + or quoted-printable URL hex (`https=3A=2F=2Frealunit.app=2Finvite=2FCODE`), + or an RFC 2047 encoded-word from an email subject + (`=?UTF-8?Q?https=3A=2F=2F…?=`, `=?UTF-8?B?…?=`), + markdown/WhatsApp emphasis (`*url*`, `_url_`), + or inline-code backticks (`` `url` ``) and fenced triple-backtick + blocks, or table pipes (`| url |`). A trailing slash or sentence + punctuation on a pasted code (`AB12CD/`, `AB12CD.`) is dropped. + A fullwidth path segment (`/invite/AB12CD`) or App Link query + code is folded onto ASCII before lookup. The landing Pages Function + applies the same fold to the HTML bytes Safari snapshots for the + Smart App Banner, including ads/Play `utm_content` / `referrer` + wrapping `invite=` or a landing URL (including `realunit.app/invite/…` + without a scheme) on a bare `/invite` path, and Facebook `u=` / Google + `q=` / Outlook `url=` / email `link=` wrapping a landing URL (`u=hello` is not a code; + a campaign name in `utm_content` does not hide a later wrapper key; + a tracking URL that only mentions `realunit.app` does not hide them either; + paste and App Links use the same try-each keys; + a foreign `https://` URL is not a code; Proofpoint URL Defense and + Outlook Safe Links wrapping a RealUnit landing are unwrapped; short + `ios-app://` alternate links too). The registration field strips those characters as + they are entered, including ASCII/Unicode spaces and the paste control (a clipboard of only + format characters is ignored, as is a RealUnit invite/promo URL with + no code, or a clipboard read failure). An empty field auto-pastes a + landing clipboard code after the deeplink stash (iOS has no Play + referrer); a typed or stashed code is not overwritten. A second paste while the + clipboard read is in flight is ignored — the paste control is not + tappable until that read finishes. A hung clipboard read is + ignored after two seconds so paste and Next are not stuck. Paste of the same code joins + the in-flight lookup. Paste after Skip (field locked, or an in-flight + clipboard read that finishes after Überspringen) is discarded. Next + awaits that in-flight paste before commitLookup. The same strip runs on the Play install referrer + before `invite=` / `promo=` / `code=` keys are read (an empty or foreign + `invite=` does not hide a later key), including fullwidth / CJK + / HTML folds so `realunit。app` still matches; a double-encoded + referrer (`invite%253D…`) is decoded again, and `utm_content` + or a nested `referrer=` / Facebook `u=` / Google `q=` / Outlook `url=` / + email `link=` wrapping `invite=` or a landing URL is unwrapped + (a campaign name in `utm_content` does not hide a later wrapper key; + a tracking URL that only mentions `realunit.app` does not hide them either), + as is a Facebook `l.php?u=` / Google `url?q=` wrapper as the whole + referrer, an Outlook Safe Links `url=`, a Proofpoint URL Defense + v2/v3 encoded landing URL, or a path-nested landing URL (Yahoo `RU=https://…`). A hung + Play Install Referrer read is timed out after 4s so app setup cannot + stall; the consumed flag is left unset. While lookup is in flight the field shows «Code wird» + geprüft…» and hides the previous result. A later lookup of the same + code (Done, paste, Retry) discards an earlier in-flight result so a + slower 4xx cannot overwrite a later 200. Next/Skip lock the field + until they finish and cancel a pending lookup debounce (Next still + awaits commitLookup and joins an in-flight lookup of the same code, + or skips a second GET when that code already has a terminal result; + Skip does not put Next into loading). Skip stays tappable while Next + is awaiting lookup so a hung GET cannot trap the user; Skip then + discards that in-flight lookup and does not let Next also advance. + A 5xx / + 401 / 408 / 429 lookup or an unmounted NestJS route is not «invalid»: + the field keeps the code, shows the unavailable copy, and offers + Retry (secondary, so Next stays the primary action). A hung lookup + GET is unavailable after 15s so Next is not stuck. Retry stays on the + unavailable copy in the loading state so a second tap is ignored + (the field is not replaced with only the checking spinner). Done still looks up a newly typed + code while a previous lookup is in flight. Skip drops a typed code immediately and leaves a + deeplink stash in place. Skip also discards an in-flight paste or + lookup so a late clipboard write or GET cannot stash after + Überspringen. After lookup the invite recognition copy or + the promo campaign dialog is shown. The campaign dialog stays until + Close — a barrier tap does not dismiss it; Close is keyboard-focused. + A 4xx bind (stacking, self-referral, + spent) shows the matching copy once (spent: «Code bereits eingelöst»; + self-referral / already-bound / already-registered have their own + sentence; unknown/expired stay «Link ungültig oder abgelaufen»). Close + is focused, not barrier-dismissible. + If the navigator is not attached yet (dashboard boot bind), the promo, + invalid, or unavailable dialog is shown on the next frame instead of + dropped. + A second bind while the POST is in flight only stashes. A retryable + bind failure does not overwrite a newer stashed code. A retryable bind + (Dart timeout, transport error, 5xx, 401, 408, 429, 504, or an unmounted + NestJS route) restashes the code and shows the unavailable copy once + (Retry focused, Close beside it, not barrier-dismissible). Retry stays + on that dialog in the loading state and binds the stash again without + waiting for the next dashboard landing; Close is not tappable while + that POST is in flight. After the POST + finishes, a leftover stash is bound next. +- History: referral payouts with amount, date, frozen CHF, announced as + one name (title, date, frozen CHF, +20 REALU). Dashboard prize rows + use the same name. A frozen CHF string with a decimal comma + (`246,5`), Swiss thousands apostrophes (`1'246.50`), or a `CHF` + prefix formats as two decimals (`246.50`). +- Deeplinks: `realunit-wallet://invite|promo/{code}` and + `https://realunit.app/invite|promo/{code}`. Release Universal Links + associate `realunit.app` and `www.realunit.app` only; Debug entitlements + add `applinks:dev.realunit.app?mode=developer` (and the same query on + apex/www) so Universal Links can be tested while production AASA is + still 404. iOS Profile/Release use App Store signing without + `?mode=developer`. + Invite bind is silent on + success; promo bind shows the campaign dialog (Close only, + keyboard-focused), including when bind returns before the navigator + has a context. Registration auto-pastes a copied landing code when the + field is empty; a deeplink stash wins over the clipboard. + +## Website + +`realunit.app/invite/{code}` and `/promo/{code}` look up the public code +route, greet by name or show the promo action text, open the app via the +custom scheme, and show App Store / Play Store badges (the matching store leads on iOS/Android). +A nested invite +URL in the path or query is unwrapped to the code, including a +path-nested landing URL on a messenger redirect and an AMP/CDN path +with `realunit.app` as a segment. `/invite#CODE` is +the hash fallback when the path has no segment and the query has no +code. Path and query codes +drop ASCII/Unicode spaces the same way as the registration field. Ads/Play +`utm_content` or `referrer` wrapping `invite=` / a landing URL is +unwrapped when the path has no code, as is Facebook `u=` / Google `q=` / +Outlook `url=` / email `link=`; a bare campaign name is not, a campaign +name in `utm_content` does not hide a later wrapper key, an empty or +foreign `code=` does not hide a later `invite=` / `promo=`, and a +foreign `https://` URL is not a code. Proofpoint URL Defense, Outlook +Safe Links, and short `ios-app://` alternate links wrapping a RealUnit +landing are unwrapped. The landing shows the +code with copy (tap / long-press) during lookup so iOS users can enter +it during registration after a fresh install. `format-detection` is +`telephone=no, date=no` so Safari does not turn that code or the +Aktionstext date into a phone or calendar link. `#ok-code` and `#ok-body` +also set `x-apple-data-detectors="false"` because lookup JS writes those +strings after load. Desktop can copy the +canonical link while lookup runs; the in-app CTA is mobile-only and +stays hidden until lookup finishes. iOS Smart App Banner `app-argument`, +`og:url`, `rel=canonical`, `twitter:url`, `og:title`, `twitter:title`, `og:description`, +`twitter:description`, `og:image:alt`, `twitter:image:alt`, `og:locale`, `og:site_name`, Play install +referrer, android-app / ios-app +alternate links, Facebook App Links (`al:ios:url` / `al:android:url` +are `realunit-wallet://…`; `al:android:class` is +`swiss.realunit.app.MainActivity`; `al:web:url` is the HTTPS landing), and Twitter +App Card `twitter:app:url:iphone` / `twitter:app:url:ipad` / +`twitter:app:url:googleplay` (same custom scheme; `twitter:app:country` is CH) +are injected into the HTML bytes from the request URL so +Safari, Play, WhatsApp, X, and share crawlers can snapshot them before JS. `?lang=en` +sets English title/description/alt and `og:locale=en_GB`. +(`/js/invite-banner.js` in `` is the CSP-safe fallback because +Cloudflare Pages CSP blocks inline `