diff --git a/.gitignore b/.gitignore index 1fc7ee30..7157ae82 100644 --- a/.gitignore +++ b/.gitignore @@ -202,3 +202,17 @@ setup.sh # gstack security reports .gstack/ + +# Playwright artifacts +playwright-report/ +test-results/ +.playwright/ +**/playwright-report/ +**/test-results/ +**/.playwright/ +**/playwright/.cache/ +**/playwright/.auth/ +playwright*.yml +playwright*.yaml +**/playwright/*.yml +**/playwright/*.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 140a337e..1464a22d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable user-facing changes to this project will be documented in this file. ## Unreleased +- Builders, validators, and community members now have a dedicated onboarding funnel. Each role's landing page adapts to whether you are signed in and shows a step-by-step journey to unlock that role: builders connect GitHub and star the boilerplate repo, community members link X and Discord, follow and join, and share a verified post on X, and validators complete the waitlist form. Completing a journey grants the role itself (points come only from verifiable tasks), sections stay locked until the relevant journey is finished, and your profile shows in-progress roles privately until earned (66677ffe) - The portal now uses clean URLs (e.g. `/hackathon` instead of `/#/hackathon`), so links you copy and share show the correct page preview on Discord, Slack, X, and other platforms; previously every shared link previewed as the generic homepage. Already-shared `/#/` links still work (84dbe5d4) - Staff can now send custom notifications from Django admin targeted at everyone, at users with selected roles (builders, validators, stewards, creators), at hand-picked users, or at a pasted batch of wallet addresses (unmatched lines are reported back). Announcements can be pure text with no link, bodies support markdown on the notifications page, drafts preview their reach before sending, and a deliberate resend resurfaces the notification as unread instead of duplicating. These campaigns are private to their recipients and the recipient-resolution step is built to be reused by future email and Telegram campaigns (4cf30bbc) - Signed-in users now get in-portal notifications: a navbar bell with unread badge and dropdown plus a full notifications page. Submission review decisions (accepted, rejected, more info needed), highlighted contributions, referrals joining, and validator graduation notify automatically; admins can explicitly broadcast featured content, alerts, partners, new contribution types, missions, Gen TV streams, POAP drops, validator-targeted node upgrade announcements, and new social tasks (sent only to the role the task's category targets: builders, validators, or community members) from Django admin (silent by default). Review notifications deep-link to the matching row in My Submissions (7bf1899c) diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 83e2124c..ee804ceb 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -371,6 +371,9 @@ GET /api/v1/users/by-address/{address}/ (requires auth) GET /api/v1/users/validators/ (requires auth) POST /api/v1/users/link_x_account/ (requires auth, awards configured points for linking X) POST /api/v1/users/link_discord_account/ (requires auth, awards configured points for linking Discord) +POST /api/v1/users/link_github_account/ (requires auth, awards community-link-github points; auto-fired by SocialLink.svelte on GitHub link) +POST /api/v1/users/start_builder_journey/ (requires auth, no-op: no longer awards points) +POST /api/v1/users/complete_builder_journey/ (requires auth, grants Builder role point-free; gated on the star-boilerplate social task) # Social Tasks GET /api/v1/social-tasks/ (?status=active|completed&category=community|builder|validator) diff --git a/backend/community_xp/services.py b/backend/community_xp/services.py index 08580829..cbae7ef2 100644 --- a/backend/community_xp/services.py +++ b/backend/community_xp/services.py @@ -10,7 +10,6 @@ from django.utils import timezone from requests import RequestException -from creators.models import Creator from social_connections.models import DiscordConnection from tally.middleware.logging_utils import get_app_logger @@ -310,22 +309,6 @@ def _connection_map(discord_ids): return by_discord_id -def _auto_create_creator_profiles(user_ids): - user_ids = {user_id for user_id in user_ids if user_id} - if not user_ids: - return 0 - - existing = set( - Creator.objects - .filter(user_id__in=user_ids) - .values_list('user_id', flat=True) - ) - missing = [Creator(user_id=user_id) for user_id in user_ids - existing] - if missing: - Creator.objects.bulk_create(missing, ignore_conflicts=True) - return len(missing) - - def store_fetch_result(run, fetch_result): now = timezone.now() discord_ids = [player.discord_id for player in fetch_result.players] @@ -388,7 +371,6 @@ def store_fetch_result(run, fetch_result): 'unmatched_players': run.unmatched_players, 'duplicate_players': run.duplicate_players, 'applied': False, - 'creator_profiles_created': 0, } @@ -451,7 +433,6 @@ def apply_sync_run(run, applied_by=None): discord_ids = [snapshot.discord_id for snapshot in snapshots] connections_by_discord_id = _connection_map(discord_ids) matched_user_ids = set() - creator_user_ids = set() current_rows = [] for snapshot in snapshots: @@ -460,8 +441,6 @@ def apply_sync_run(run, applied_by=None): matched_at = now if matched_user else None if matched_user: matched_user_ids.add(matched_user.id) - if snapshot.xp > 0: - creator_user_ids.add(matched_user.id) current_rows.append(Mee6CurrentXP( guild_id=snapshot.guild_id, @@ -484,7 +463,6 @@ def apply_sync_run(run, applied_by=None): with transaction.atomic(): Mee6CurrentXP.objects.filter(guild_id=run.guild_id).delete() Mee6CurrentXP.objects.bulk_create(current_rows, batch_size=1000) - creator_profiles_created = _auto_create_creator_profiles(creator_user_ids) run.matched_players = len(matched_user_ids) run.unmatched_players = len(snapshots) - len(matched_user_ids) @@ -505,7 +483,6 @@ def apply_sync_run(run, applied_by=None): 'players_applied': len(snapshots), 'matched_players': run.matched_players, 'unmatched_players': run.unmatched_players, - 'creator_profiles_created': creator_profiles_created, 'applied_at': run.applied_at, } @@ -549,7 +526,7 @@ def run_mee6_sync(guild_id=None, page_size=None, client=None, use_lock=True): release_sync_lock(owner_token) -def match_current_xp_for_connection(connection, guild_id=None, create_creator=True): +def match_current_xp_for_connection(connection, guild_id=None): guild_id = str(guild_id or get_default_guild_id()) discord_id = str(connection.platform_user_id or '') if not discord_id: @@ -571,9 +548,6 @@ def match_current_xp_for_connection(connection, guild_id=None, create_creator=Tr current.matched_at = timezone.now() current.save(update_fields=['matched_user', 'matched_at', 'updated_at']) - if create_creator and current.xp > 0: - Creator.objects.get_or_create(user=connection.user) - return current diff --git a/backend/community_xp/tests/test_mee6_sync.py b/backend/community_xp/tests/test_mee6_sync.py index f0407fd0..9f37678b 100644 --- a/backend/community_xp/tests/test_mee6_sync.py +++ b/backend/community_xp/tests/test_mee6_sync.py @@ -90,13 +90,15 @@ def setUp(self): max_points=1_000_000, is_submittable=True, ) - self.discord_link_type = ContributionType.objects.create( - name='Link Discord Account', + self.discord_link_type, _ = ContributionType.objects.update_or_create( slug='community-link-discord', - category=self.community_category, - min_points=20, - max_points=20, - is_submittable=False, + defaults={ + 'name': 'Link Discord Account', + 'category': self.community_category, + 'min_points': 20, + 'max_points': 20, + 'is_submittable': False, + }, ) GlobalLeaderboardMultiplier.objects.create( contribution_type=self.community_type, @@ -326,7 +328,7 @@ def test_sync_preserves_contributions_and_counts_mee6_plus_pending_portal_points self.assertEqual(breakdown['pending_portal_points'], 50) self.assertEqual(breakdown['tracked_portal_points_all_time'], 50) self.assertEqual(breakdown['total_points'], 150) - self.assertTrue(Creator.objects.filter(user=self.user).exists()) + self.assertFalse(Creator.objects.filter(user=self.user).exists()) def test_post_baseline_distributed_points_count_until_next_mee6_baseline(self): self.link_discord(self.user) @@ -360,6 +362,7 @@ def test_post_baseline_distributed_points_count_until_next_mee6_baseline(self): self.assertEqual(Contribution.objects.count(), 2) def test_unmatched_snapshot_is_stored_without_creating_user(self): + user_count = User.objects.count() run = self.fetch_mee6_run([mee6_player('unmatched-discord', 77)]) snapshot = Mee6PlayerSnapshot.objects.get(discord_id='unmatched-discord') @@ -370,9 +373,9 @@ def test_unmatched_snapshot_is_stored_without_creating_user(self): current = Mee6CurrentXP.objects.get(discord_id='unmatched-discord') self.assertIsNone(current.matched_user) - self.assertEqual(User.objects.count(), 1) + self.assertEqual(User.objects.count(), user_count) - def test_late_discord_link_matches_current_xp_and_creates_creator_profile(self): + def test_late_discord_link_matches_current_xp_without_creating_creator_profile(self): run = self.fetch_mee6_run([mee6_player('late-discord', 77)]) late_user = User.objects.create_user( email='late@example.com', @@ -389,7 +392,7 @@ def test_late_discord_link_matches_current_xp_and_creates_creator_profile(self): breakdown = get_effective_community_points(late_user) self.assertEqual(current.matched_user, late_user) - self.assertTrue(Creator.objects.filter(user=late_user).exists()) + self.assertFalse(Creator.objects.filter(user=late_user).exists()) self.assertEqual(breakdown['discord_xp'], 77) self.assertEqual(breakdown['total_points'], 77) diff --git a/backend/contributions/migrations/0073_create_community_link_github.py b/backend/contributions/migrations/0073_create_community_link_github.py new file mode 100644 index 00000000..db29b1c3 --- /dev/null +++ b/backend/contributions/migrations/0073_create_community_link_github.py @@ -0,0 +1,54 @@ +"""GitHub-link reward (builder category) + builder-welcome as a 0-point marker. + +Merged from the former 0073 + 0074 into one migration. + +- Linking GitHub is a BUILDER action, so the reward type lives in the `builder` + category (it feeds the builder leaderboard / shows as a builder contribution). + The slug stays `community-link-github` and the serializer field stays + `has_community_link_github` to match the existing link-reward family and the + frontend that already consumes that field. +- `builder-welcome` is no longer a farmable +20 reward; it is the point-free + "started the builder journey" marker, so its type is relaxed to 0 points. +""" + +from django.db import migrations + + +def apply(apps, schema_editor): + Category = apps.get_model('contributions', 'Category') + ContributionType = apps.get_model('contributions', 'ContributionType') + + builder_category = Category.objects.filter(slug='builder').first() + if builder_category is None: + raise RuntimeError('builder category missing; cannot seed community-link-github') + ContributionType.objects.update_or_create( + slug='community-link-github', + defaults={ + 'name': 'Link GitHub Account', + 'description': 'Linked your GitHub account to your GenLayer profile', + 'category': builder_category, + 'min_points': 25, + 'max_points': 25, + 'is_default': False, + 'is_submittable': False, + }, + ) + + ContributionType.objects.filter(slug='builder-welcome').update(min_points=0, max_points=0) + + +def reverse(apps, schema_editor): + ContributionType = apps.get_model('contributions', 'ContributionType') + ContributionType.objects.filter(slug='community-link-github').delete() + ContributionType.objects.filter(slug='builder-welcome').update(min_points=20, max_points=20) + + +class Migration(migrations.Migration): + + dependencies = [ + ('contributions', '0072_submittedcontribution_gate_reviewed'), + ] + + operations = [ + migrations.RunPython(apply, reverse), + ] diff --git a/backend/contributions/tests/test_is_submittable.py b/backend/contributions/tests/test_is_submittable.py index 242f7e5c..5663db6d 100644 --- a/backend/contributions/tests/test_is_submittable.py +++ b/backend/contributions/tests/test_is_submittable.py @@ -259,8 +259,11 @@ def setUp(self): def test_steward_can_see_all_types_without_filter(self): """Test that when no is_submittable filter is provided, all types are returned.""" - # This simulates steward mode where they don't send the is_submittable filter - response = self.client.get(self.api_url) + # This simulates steward mode where they don't send the is_submittable filter. + # Request a large page so all test types stay on one page (the default + # page size is 10; new system contribution types must not push them onto + # page 2 and break this count). + response = self.client.get(self.api_url, {'page_size': 100}) self.assertEqual(response.status_code, status.HTTP_200_OK) data = response.json() diff --git a/backend/creators/community_journey.py b/backend/creators/community_journey.py new file mode 100644 index 00000000..e7ef1925 --- /dev/null +++ b/backend/creators/community_journey.py @@ -0,0 +1,132 @@ +"""Community journey logic: the 5 steps to become a Creator (community member). + +1. Link X -> `community-link-x` contribution (existing reward) +2. Link Discord -> `community-link-discord` contribution (existing reward) +3. Follow GenLayer -> `follow-genlayer-x` social task completion +4. Join Discord -> `join-genlayer-discord` social task completion +5. X post with code -> a CommunityPostProof, verified via Sorsa /tweet-info + +Completing all 5 grants the Creator role (point-free); steps 1-4 keep their own +existing points. The X-post step uses a deterministic per-user code (no storage +of the code needed) and the post must @mention GenLayer and come from the user's +linked X account. +""" + +import hashlib +import hmac +import re +from urllib.parse import quote + +from django.conf import settings + +LINK_X_SLUG = 'community-link-x' +LINK_DISCORD_SLUG = 'community-link-discord' +FOLLOW_TASK_SLUG = 'follow-genlayer-x' +JOIN_DISCORD_TASK_SLUG = 'join-genlayer-discord' +WELCOME_SLUG = 'community-welcome' + +CODE_PREFIX = 'GL-' + +# A well-formed X / Twitter post URL: https://x.com//status/ +X_POST_RE = re.compile( + r'^https?://(?:www\.)?(?:x\.com|twitter\.com)/' + r'(?P[A-Za-z0-9_]{1,15})/status(?:es)?/(?P\d+)', + re.IGNORECASE, +) + + +def genlayer_handle() -> str: + return getattr(settings, 'GENLAYER_X_HANDLE', 'genlayer').lstrip('@').lower() + + +def verification_code(user) -> str: + """Deterministic per-user code embedded in the X post. Recomputed at verify + time, so nothing is stored. Tied to SECRET_KEY so it cannot be guessed.""" + digest = hmac.new( + settings.SECRET_KEY.encode(), + f'community-x-post:{user.pk}'.encode(), + hashlib.sha256, + ).hexdigest()[:8].upper() + return f'{CODE_PREFIX}{digest}' + + +def share_text(user) -> str: + return f"I'm joining the @{genlayer_handle()} community! {verification_code(user)}" + + +def intent_url(user) -> str: + return f'https://x.com/intent/post?text={quote(share_text(user))}' + + +def parse_x_post(url: str): + """Return (handle_lower, tweet_id) for a well-formed X post URL, else (None, None).""" + match = X_POST_RE.match((url or '').strip()) + if not match: + return None, None + return match.group('handle').lower(), match.group('id') + + +def post_matches(full_text: str, user): + """Whether the tweet text contains the user's code and @mentions GenLayer. + Returns (ok, error_code).""" + text = (full_text or '').lower() + if verification_code(user).lower() not in text: + return False, 'code_missing' + handle_re = re.compile(rf'(^|[^a-z0-9_])@{re.escape(genlayer_handle())}(?![a-z0-9_])') + if not handle_re.search(text): + return False, 'tag_missing' + return True, None + + +def _has_contribution(user, slug) -> bool: + from contributions.models import Contribution + return Contribution.objects.filter(user=user, contribution_type__slug=slug).exists() + + +def _has_task_completion(user, slug) -> bool: + from social_tasks.models import SocialTaskCompletion + return SocialTaskCompletion.objects.filter(user=user, task__slug=slug).exists() + + +def is_started(user) -> bool: + return _has_contribution(user, WELCOME_SLUG) + + +def step_states(user) -> dict: + return { + 'link_x': _has_contribution(user, LINK_X_SLUG), + 'link_discord': _has_contribution(user, LINK_DISCORD_SLUG), + 'follow_x': _has_task_completion(user, FOLLOW_TASK_SLUG), + 'join_discord': _has_task_completion(user, JOIN_DISCORD_TASK_SLUG), + 'x_post': hasattr(user, 'community_post_proof'), + } + + +def journey_status(user) -> dict: + # Existing community members (the Creator role) are grandfathered in: the + # journey only applies to newcomers, so a member is always treated as + # started/complete regardless of the newer welcome-marker + step records. + is_creator = hasattr(user, 'creator') + states = step_states(user) + started = is_creator or is_started(user) + missing_steps = [key for key, done in states.items() if not done] + proof = getattr(user, 'community_post_proof', None) + return { + 'started': started, + 'steps': { + 'link_x': {'done': states['link_x']}, + 'link_discord': {'done': states['link_discord']}, + 'follow_x': {'done': states['follow_x']}, + 'join_discord': {'done': states['join_discord']}, + 'x_post': { + 'done': states['x_post'], + 'verification_code': verification_code(user), + 'share_text': share_text(user), + 'intent_url': intent_url(user), + 'post_url': proof.post_url if proof else None, + }, + }, + 'missing_steps': missing_steps, + 'complete': is_creator or (started and not missing_steps), + 'is_member': is_creator, + } diff --git a/backend/creators/migrations/0002_backfill_community_members.py b/backend/creators/migrations/0002_backfill_community_members.py index aff0c57d..8d7ead55 100644 --- a/backend/creators/migrations/0002_backfill_community_members.py +++ b/backend/creators/migrations/0002_backfill_community_members.py @@ -2,31 +2,9 @@ def backfill_community_members(apps, schema_editor): - Creator = apps.get_model('creators', 'Creator') - Contribution = apps.get_model('contributions', 'Contribution') - PoapClaim = apps.get_model('poaps', 'PoapClaim') - - user_ids = set( - Contribution.objects.filter( - contribution_type__category__slug='community', - ).values_list('user_id', flat=True).distinct() - ) - user_ids.update( - PoapClaim.objects.filter( - user__isnull=False, - ).values_list('user_id', flat=True).distinct() - ) - - existing_user_ids = set( - Creator.objects.filter(user_id__in=user_ids).values_list('user_id', flat=True) - ) - missing_user_ids = user_ids - existing_user_ids - - Creator.objects.bulk_create( - [Creator(user_id=user_id) for user_id in missing_user_ids], - ignore_conflicts=True, - batch_size=500, - ) + # Creator rows are granted only through the community journey completion + # endpoint. Keep this historical migration as a no-op for fresh databases. + return class Migration(migrations.Migration): diff --git a/backend/creators/migrations/0003_communitypostproof.py b/backend/creators/migrations/0003_communitypostproof.py new file mode 100644 index 00000000..7809dc78 --- /dev/null +++ b/backend/creators/migrations/0003_communitypostproof.py @@ -0,0 +1,31 @@ +# Generated by Django 6.0.6 on 2026-06-25 12:54 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('creators', '0002_backfill_community_members'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CommunityPostProof', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('post_url', models.URLField()), + ('tweet_id', models.CharField(blank=True, max_length=40)), + ('verified_at', models.DateTimeField(auto_now=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='community_post_proof', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/backend/creators/models.py b/backend/creators/models.py index f22fd219..524450a0 100644 --- a/backend/creators/models.py +++ b/backend/creators/models.py @@ -16,3 +16,19 @@ class Creator(BaseModel): def __str__(self): return f"{self.user.email} - Creator" + + +class CommunityPostProof(BaseModel): + """Step 5 of the community journey: the verified X post tagging GenLayer + with the user's generated code. One per user.""" + user = models.OneToOneField( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='community_post_proof', + ) + post_url = models.URLField() + tweet_id = models.CharField(max_length=40, blank=True) + verified_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.user.email} - community X post" diff --git a/backend/creators/utils.py b/backend/creators/utils.py deleted file mode 100644 index 425299a5..00000000 --- a/backend/creators/utils.py +++ /dev/null @@ -1,26 +0,0 @@ -from creators.models import Creator - - -def ensure_creator_status(user): - if not user or not getattr(user, 'pk', None): - return None - - creator, _ = Creator.objects.get_or_create(user=user) - return creator - - -def ensure_creator_status_for_users(user_ids): - user_ids = {user_id for user_id in user_ids if user_id} - if not user_ids: - return - - existing_user_ids = set( - Creator.objects.filter(user_id__in=user_ids).values_list('user_id', flat=True) - ) - missing_user_ids = user_ids - existing_user_ids - if missing_user_ids: - Creator.objects.bulk_create( - [Creator(user_id=user_id) for user_id in missing_user_ids], - ignore_conflicts=True, - batch_size=500, - ) diff --git a/backend/creators/views.py b/backend/creators/views.py index 766c2429..e58cc3c0 100644 --- a/backend/creators/views.py +++ b/backend/creators/views.py @@ -1,7 +1,10 @@ +from django.db import transaction from rest_framework import status from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response + +from creators import community_journey as cj from .models import Creator from users.serializers import CreatorSerializer @@ -10,17 +13,46 @@ @permission_classes([IsAuthenticated]) def join_creator_view(request): """ - Allow authenticated users to join the community program. + Legacy creator join endpoint. + + Keep the route for old clients, but do not let it bypass the community + journey. The Creator row is granted only after the journey has started and + all required steps are complete. """ user = request.user - if hasattr(user, 'creator'): + journey = cj.journey_status(user) + if not journey['started']: return Response( - {'message': 'You are already a community member!'}, + { + 'error': 'not_started', + 'missing_steps': ['start'], + 'message': 'Start the community journey first.', + }, status=status.HTTP_400_BAD_REQUEST ) + if not journey['complete']: + return Response( + { + 'error': 'incomplete', + 'missing_steps': journey['missing_steps'], + 'message': 'Complete all community journey steps first.', + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + with transaction.atomic(): + creator, created = Creator.objects.get_or_create(user=user) + + if not created: + serializer = CreatorSerializer(creator) + return Response({ + 'message': 'You are already a community member!', + 'creator': serializer.data, + }, status=status.HTTP_200_OK) - creator = Creator.objects.create(user=user) + from leaderboard.models import update_user_leaderboard_entries + update_user_leaderboard_entries(user) serializer = CreatorSerializer(creator) return Response({ diff --git a/backend/leaderboard/models.py b/backend/leaderboard/models.py index 28a8f151..709eef20 100644 --- a/backend/leaderboard/models.py +++ b/backend/leaderboard/models.py @@ -405,14 +405,6 @@ def update_leaderboard_on_contribution(sender, instance, created, **kwargs): logger.debug(f"Contribution saved: {instance.points} points × {instance.multiplier_at_creation} = " f"{instance.frozen_global_points} global points") - if ( - instance.contribution_type - and instance.contribution_type.category - and instance.contribution_type.category.slug == 'community' - ): - from creators.utils import ensure_creator_status - ensure_creator_status(instance.user) - # Update the user's leaderboard entries update_user_leaderboard_entries(instance.user) @@ -664,43 +656,19 @@ def update_referrer_points(contribution): def ensure_builder_status(user, _reference_date): """ - Create missing builder-welcome, builder contributions and Builder profile. - Used to auto-grant builder status to users who have builder contributions. + Ensure a Builder profile exists for a user with accepted builder + contributions (steward-accept and leaderboard recalc paths). + + Point-free: the builder-welcome (+20) and builder (+50) point grants were + removed as farming. The role itself is granted (a steward accepting a + builder contribution is a strong, human-gated signal), but the journey is + the only path that awards points, and those only via verifiable tasks. """ from builders.models import Builder - try: - welcome_type = ContributionType.objects.get(slug='builder-welcome') - builder_type = ContributionType.objects.get(slug='builder') - except ContributionType.DoesNotExist: - return - if not hasattr(user, 'builder'): Builder.objects.create(user=user) - contributions_to_create = [] - - if not Contribution.objects.filter(user=user, contribution_type=welcome_type).exists(): - contributions_to_create.append(Contribution( - user=user, - contribution_type=welcome_type, - points=20, - contribution_date=timezone.now(), - frozen_global_points=20 - )) - - if not Contribution.objects.filter(user=user, contribution_type=builder_type).exists(): - contributions_to_create.append(Contribution( - user=user, - contribution_type=builder_type, - points=50, - contribution_date=timezone.now(), - frozen_global_points=50 - )) - - if contributions_to_create: - Contribution.objects.bulk_create(contributions_to_create) - @transaction.atomic def recalculate_referrer_points(referrer): diff --git a/backend/leaderboard/tests/test_stats.py b/backend/leaderboard/tests/test_stats.py index 7923521d..dcac5fa0 100644 --- a/backend/leaderboard/tests/test_stats.py +++ b/backend/leaderboard/tests/test_stats.py @@ -256,7 +256,7 @@ def test_community_member_count_uses_accepted_community_contributions(self): self.assertEqual(response.data['creator_count'], 2) self.assertEqual(response.data['builder_count'], 1) - def test_poap_claim_grants_role_and_counts_as_member_metric(self): + def test_poap_claim_counts_as_member_metric_without_granting_role(self): poap_user = self._create_user( 'poap@example.com', '0x0000000000000000000000000000000000000007' @@ -279,7 +279,7 @@ def test_poap_claim_grants_role_and_counts_as_member_metric(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.data['community_member_count'], 1) self.assertEqual(response.data['creator_count'], 1) - self.assertTrue(Creator.objects.filter(user=poap_user).exists()) + self.assertFalse(Creator.objects.filter(user=poap_user).exists()) def test_validator_count_uses_visible_validator_table_rows(self): validator_user = self._create_user( @@ -546,7 +546,7 @@ def test_mission_backed_non_submittable_community_contribution_is_reflected(self self.assertEqual(stats_response.data['community_member_count'], 1) self.assertEqual(stats_response.data['new_community_members_count'], 1) self.assertEqual(stats_response.data['contribution_count'], 1) - self.assertTrue(Creator.objects.filter(user=contributor).exists()) + self.assertFalse(Creator.objects.filter(user=contributor).exists()) self.assertEqual(monthly_response.status_code, 200) self.assertEqual(monthly_response.data[0]['user'], contributor.id) diff --git a/backend/poaps/management/commands/import_poap_archive.py b/backend/poaps/management/commands/import_poap_archive.py index 604e75fa..bc72ebf1 100644 --- a/backend/poaps/management/commands/import_poap_archive.py +++ b/backend/poaps/management/commands/import_poap_archive.py @@ -369,12 +369,6 @@ def _import_claim_rows(self, batch, drop, drop_entry, rows, errors): if claims_to_create: PoapClaim.objects.bulk_create(claims_to_create, batch_size=500) - from creators.utils import ensure_creator_status_for_users - ensure_creator_status_for_users( - [claim.user_id for claim in claims_to_update] - + [claim.user_id for claim in claims_to_create] - ) - batch.imported_count += imported_count batch.matched_count += matched_count batch.unmatched_count += unmatched_count diff --git a/backend/poaps/signals.py b/backend/poaps/signals.py index 0f55249b..12b8a23d 100644 --- a/backend/poaps/signals.py +++ b/backend/poaps/signals.py @@ -2,17 +2,9 @@ from django.db.models.signals import post_save from django.dispatch import receiver -from creators.utils import ensure_creator_status -from .models import PoapClaim from .services import attach_unmatched_claims_for_user @receiver(post_save, sender=get_user_model()) def attach_legacy_poap_claims(sender, instance, **kwargs): attach_unmatched_claims_for_user(instance) - - -@receiver(post_save, sender=PoapClaim) -def grant_community_role_for_poap_claim(sender, instance, **kwargs): - if instance.user_id: - ensure_creator_status(instance.user) diff --git a/backend/social_tasks/migrations/0004_seed_builder_star_task.py b/backend/social_tasks/migrations/0004_seed_builder_star_task.py new file mode 100644 index 00000000..246000a4 --- /dev/null +++ b/backend/social_tasks/migrations/0004_seed_builder_star_task.py @@ -0,0 +1,61 @@ +"""Seed the builder-journey "star the boilerplate repo" social task. + +This is the single points-bearing step of the builder journey: completing it +(a real GitHub star, via the github_star verifier) awards points AND is the gate +for the point-free Builder role grant (see users.views.complete_builder_journey +and settings.BUILDER_JOURNEY_TASK_SLUG). + +Historical models skip the model's custom save(), so platform/action_url are set +explicitly here. +""" + +from django.conf import settings +from django.db import migrations + + +TASK_SLUG = 'star-genlayer-boilerplate' + + +def seed_task(apps, schema_editor): + Category = apps.get_model('contributions', 'Category') + SocialTask = apps.get_model('social_tasks', 'SocialTask') + + builder, _ = Category.objects.get_or_create( + slug='builder', + defaults={'name': 'Builder', 'description': 'Builder contributions and tasks.'}, + ) + + repo = getattr(settings, 'GITHUB_REPO_TO_STAR', 'genlayerlabs/genlayer-project-boilerplate') + + SocialTask.objects.update_or_create( + slug=TASK_SLUG, + defaults={ + 'name': 'Star the GenLayer boilerplate', + 'description': 'Star the GenLayer project boilerplate on GitHub to kick off your builder journey.', + 'category': builder, + 'points': 25, # matches the community-link-github reward; admin-tunable + 'verification_type': 'github_star', + 'target_repo': repo, + 'action_url': f'https://github.com/{repo}', + 'cta_text': 'Star repo', + 'platform': 'github', + 'is_active': True, + 'order': 10, + }, + ) + + +def noop_reverse(apps, schema_editor): + """Keep the row (and any admin edits / completions referencing it) on rollback.""" + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('social_tasks', '0003_deactivate_check_out_genlayer_x'), + ] + + operations = [ + migrations.RunPython(seed_task, noop_reverse), + ] diff --git a/backend/social_tasks/sorsa_client.py b/backend/social_tasks/sorsa_client.py index 8d7cf362..72e700a5 100644 --- a/backend/social_tasks/sorsa_client.py +++ b/backend/social_tasks/sorsa_client.py @@ -32,6 +32,7 @@ # Code constants — wire shape lives next to the parser below so any change # to Sorsa's contract is a one-file diff. SORSA_FOLLOW_PATH = '/check-follow' +SORSA_TWEET_INFO_PATH = '/tweet-info' SORSA_TIMEOUT_SECONDS = 8.0 @@ -119,6 +120,60 @@ def is_following(self, actor_handle: str, target_handle: str) -> tuple[bool, dic } return is_following, audit + def get_tweet(self, tweet_link: str) -> dict[str, str] | None: + """Fetch a single tweet via POST /tweet-info. + + `tweet_link` accepts a full tweet URL or a bare tweet id. + Returns {'full_text': str, 'username': str} (username without @), or + None when the tweet is not found / deleted (404 — a verification + failure, not an outage). Raises SorsaError on transport / auth / server + / parse failures so the caller can surface a retryable 503. + """ + if not self.base_url or not self.api_key: + raise SorsaError('Sorsa is not configured (SORSA_API_BASE_URL / SORSA_API_KEY missing)') + + url = f'{self.base_url}{SORSA_TWEET_INFO_PATH}' + headers = { + 'ApiKey': self.api_key, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + } + + try: + with trace_external('sorsa', 'tweet_info'): + response = self._session.post( + url, json={'tweet_link': tweet_link}, headers=headers, timeout=self.timeout + ) + except requests.RequestException as exc: + raise SorsaError(f'Sorsa request failed: {exc}') from exc + + if response.status_code == 404: + return None + if response.status_code >= 500: + raise SorsaError(f'Sorsa returned {response.status_code}') + if response.status_code in (401, 403): + raise SorsaError(f'Sorsa auth failed ({response.status_code})') + + try: + data = response.json() + except ValueError as exc: + raise SorsaError('Sorsa returned non-JSON response') from exc + + if response.status_code >= 400: + raise SorsaError(f'Sorsa error {response.status_code}: {data}') + + full_text = data.get('full_text') + if not isinstance(full_text, str): + raise SorsaError('Unexpected tweet-info response (no full_text)') + user_payload = data.get('user') + if not isinstance(user_payload, dict): + raise SorsaError('Unexpected tweet-info response (no user object)') + username = user_payload.get('username') or '' + if not isinstance(username, str): + raise SorsaError('Unexpected tweet-info response (invalid username)') + username = username.lstrip('@') + return {'full_text': full_text, 'username': username} + _default_client: SorsaClient | None = None diff --git a/backend/tally/settings.py b/backend/tally/settings.py index e52a9415..bac150e3 100644 --- a/backend/tally/settings.py +++ b/backend/tally/settings.py @@ -261,12 +261,19 @@ def get_required_env(key): GITHUB_REDIRECT_URI = f"{BACKEND_URL}/api/auth/github/callback/" GITHUB_ENCRYPTION_KEY = os.environ.get('GITHUB_ENCRYPTION_KEY', '') GITHUB_REPO_TO_STAR = os.environ.get('GITHUB_REPO_TO_STAR', 'genlayerlabs/genlayer-project-boilerplate') +# Slug of the social task that gates the (point-free) builder role grant. Keep in +# sync with the seed in social_tasks migration 0004; renaming/deactivating that +# task blocks builder completion until it's restored. +BUILDER_JOURNEY_TASK_SLUG = os.environ.get('BUILDER_JOURNEY_TASK_SLUG', 'star-genlayer-boilerplate') # Twitter/X OAuth settings TWITTER_CLIENT_ID = os.environ.get('TWITTER_CLIENT_ID', '') TWITTER_CLIENT_SECRET = os.environ.get('TWITTER_CLIENT_SECRET', '') TWITTER_REDIRECT_URI = os.environ.get('TWITTER_REDIRECT_URI', f"{BACKEND_URL}/api/auth/twitter/callback/") X_METRICS_USERNAME = os.environ.get('X_METRICS_USERNAME', 'GenLayer') +# Handle the community-journey X post must @mention (without @). Used to verify +# the post tags GenLayer. +GENLAYER_X_HANDLE = os.environ.get('GENLAYER_X_HANDLE', 'genlayer') # Discord OAuth settings DISCORD_CLIENT_ID = os.environ.get('DISCORD_CLIENT_ID', '') diff --git a/backend/users/serializers.py b/backend/users/serializers.py index 099eb1ce..a98a4885 100644 --- a/backend/users/serializers.py +++ b/backend/users/serializers.py @@ -567,8 +567,11 @@ class UserSerializer(serializers.ModelSerializer): creator = CreatorSerializer(read_only=True) has_validator_waitlist = serializers.SerializerMethodField() has_builder_welcome = serializers.SerializerMethodField() + has_validator_welcome = serializers.SerializerMethodField() + has_community_welcome = serializers.SerializerMethodField() has_community_link_x = serializers.SerializerMethodField() has_community_link_discord = serializers.SerializerMethodField() + has_community_link_github = serializers.SerializerMethodField() email = serializers.SerializerMethodField() # Referral system fields @@ -596,7 +599,9 @@ class Meta: model = User fields = ['id', 'name', 'address', 'visible', 'leaderboard_entry', 'validator', 'builder', 'steward', 'creator', 'has_validator_waitlist', 'has_builder_welcome', - 'has_community_link_x', 'has_community_link_discord', 'created_at', 'updated_at', + 'has_validator_welcome', 'has_community_welcome', + 'has_community_link_x', 'has_community_link_discord', 'has_community_link_github', + 'created_at', 'updated_at', # Profile fields 'description', 'banner_image_url', 'profile_image_url', 'website', 'twitter_handle', 'discord_handle', 'telegram_handle', 'linkedin_handle', @@ -677,44 +682,43 @@ def get_has_validator_waitlist(self, obj): except ContributionType.DoesNotExist: return False - def get_has_builder_welcome(self, obj): - """ - Check if user has the builder welcome badge (contribution). + def _has_contribution_type(self, obj, slug): + """Whether the user has any contribution of the given type slug. + Skip for nested/list views to avoid N+1 queries. """ - # Skip expensive queries for nested/list views if self.context.get('use_light_serializers', False): return False - from contributions.models import Contribution, ContributionType - try: - welcome_type = ContributionType.objects.get(slug='builder-welcome') - return Contribution.objects.filter(user=obj, contribution_type=welcome_type).exists() + contribution_type = ContributionType.objects.get(slug=slug) + return Contribution.objects.filter(user=obj, contribution_type=contribution_type).exists() except ContributionType.DoesNotExist: return False - + + def get_has_builder_welcome(self, obj): + """Check if the user started the builder journey (point-free marker).""" + return self._has_contribution_type(obj, 'builder-welcome') + + def get_has_validator_welcome(self, obj): + """Check if the user started the validator journey (point-free marker).""" + return self._has_contribution_type(obj, 'validator-welcome') + + def get_has_community_welcome(self, obj): + """Check if the user started the community journey (point-free marker).""" + return self._has_contribution_type(obj, 'community-welcome') + def get_has_community_link_x(self, obj): """Check if user has earned points for linking X account.""" - if self.context.get('use_light_serializers', False): - return False - from contributions.models import Contribution, ContributionType - try: - link_type = ContributionType.objects.get(slug='community-link-x') - return Contribution.objects.filter(user=obj, contribution_type=link_type).exists() - except ContributionType.DoesNotExist: - return False + return self._has_contribution_type(obj, 'community-link-x') def get_has_community_link_discord(self, obj): """Check if user has earned points for linking Discord account.""" - if self.context.get('use_light_serializers', False): - return False - from contributions.models import Contribution, ContributionType - try: - link_type = ContributionType.objects.get(slug='community-link-discord') - return Contribution.objects.filter(user=obj, contribution_type=link_type).exists() - except ContributionType.DoesNotExist: - return False + return self._has_contribution_type(obj, 'community-link-discord') + + def get_has_community_link_github(self, obj): + """Check if user has earned points for linking GitHub account.""" + return self._has_contribution_type(obj, 'community-link-github') def _can_view_private_user_data(self, obj): request = self.context.get('request') @@ -890,6 +894,22 @@ def to_representation(self, instance): ]: data.pop(field, None) + # In-progress funnel state is owner-only: it must not leak to other + # viewers on a public profile, but the owner viewing their own public + # profile keeps it so their grey "only you can see this" in-progress + # indicator still renders. (has_validator_waitlist stays for everyone: + # the waitlist is already public.) + if not self._can_view_private_user_data(instance): + for field in [ + 'has_builder_welcome', + 'has_validator_welcome', + 'has_community_welcome', + 'has_community_link_x', + 'has_community_link_discord', + 'has_community_link_github', + ]: + data.pop(field, None) + return data diff --git a/backend/users/tests/test_builder_journey.py b/backend/users/tests/test_builder_journey.py new file mode 100644 index 00000000..f40a839a --- /dev/null +++ b/backend/users/tests/test_builder_journey.py @@ -0,0 +1,184 @@ +"""Builder journey is point-free: the role is granted, no points awarded. + +Points come only from the boilerplate-star social task, which also gates the +role grant. The old farmable `builder-welcome` (+20) / `builder` (+50) awards +are gone. +""" + +from django.conf import settings +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APIClient + +from contributions.models import Category, Contribution, ContributionType +from leaderboard.models import GlobalLeaderboardMultiplier +from social_tasks.models import SocialTask, SocialTaskCompletion + +User = get_user_model() + + +class BuilderJourneyTests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user( + email='builder@test.com', + address='0x1234567890123456789012345678901234567890', + password='testpass123', + ) + self.client.force_authenticate(user=self.user) + self.builder_cat, _ = Category.objects.get_or_create( + slug='builder', defaults={'name': 'Builder', 'description': 'Builder tasks.'}, + ) + # Self-contained: ensure the gate task exists regardless of seed state. + self.star_task, _ = SocialTask.objects.get_or_create( + slug=settings.BUILDER_JOURNEY_TASK_SLUG, + defaults={ + 'name': 'Star the GenLayer boilerplate', + 'category': self.builder_cat, + 'points': 500, + 'verification_type': 'github_star', + 'target_repo': 'genlayerlabs/genlayer-project-boilerplate', + 'action_url': 'https://github.com/genlayerlabs/genlayer-project-boilerplate', + 'platform': 'github', + }, + ) + + def complete_star_task(self): + return SocialTaskCompletion.objects.create( + user=self.user, + task=self.star_task, + points_awarded=self.star_task.points, + verification_type='github_star', + ) + + def test_seed_migration_created_builder_star_task(self): + """The 0004 seed registers the gate task as a builder github_star task.""" + task = SocialTask.objects.get(slug=settings.BUILDER_JOURNEY_TASK_SLUG) + self.assertEqual(task.verification_type, 'github_star') + self.assertEqual(task.category.slug, 'builder') + + def test_start_builder_journey_creates_point_free_marker(self): + response = self.client.post('/api/v1/users/start_builder_journey/') + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + # A 0-point "builder-welcome" marker persists "journey started" — no points. + marker = Contribution.objects.get( + user=self.user, contribution_type__slug='builder-welcome' + ) + self.assertEqual(marker.points, 0) + self.assertFalse(hasattr(self.user, 'builder')) + + def test_start_builder_journey_is_idempotent(self): + first = self.client.post('/api/v1/users/start_builder_journey/') + second = self.client.post('/api/v1/users/start_builder_journey/') + + self.assertEqual(first.status_code, status.HTTP_201_CREATED) + self.assertEqual(second.status_code, status.HTTP_200_OK) + self.assertEqual( + Contribution.objects.filter( + user=self.user, contribution_type__slug='builder-welcome' + ).count(), + 1, + ) + + def test_start_role_journey_marks_validator_and_community_point_free(self): + for role, slug in [('validator', 'validator-welcome'), ('community', 'community-welcome')]: + resp = self.client.post('/api/v1/users/start_role_journey/', {'role': role}) + self.assertEqual(resp.status_code, status.HTTP_201_CREATED) + marker = Contribution.objects.get( + user=self.user, contribution_type__slug=slug + ) + self.assertEqual(marker.points, 0) + + def test_start_role_journey_is_idempotent(self): + first = self.client.post('/api/v1/users/start_role_journey/', {'role': 'community'}) + second = self.client.post('/api/v1/users/start_role_journey/', {'role': 'community'}) + + self.assertEqual(first.status_code, status.HTTP_201_CREATED) + self.assertEqual(second.status_code, status.HTTP_200_OK) + self.assertEqual( + Contribution.objects.filter( + user=self.user, contribution_type__slug='community-welcome' + ).count(), + 1, + ) + + def test_start_role_journey_rejects_unknown_role(self): + resp = self.client.post('/api/v1/users/start_role_journey/', {'role': 'steward'}) + self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST) + + def test_start_validator_journey_also_marks_journey_started(self): + validator_cat, _ = Category.objects.get_or_create( + slug='validator', + defaults={'name': 'Validator', 'description': 'Validator tasks.'}, + ) + waitlist_type, _ = ContributionType.objects.update_or_create( + slug='validator-waitlist', + defaults={ + 'name': 'Validator Waitlist', + 'category': validator_cat, + 'is_submittable': False, + 'min_points': 0, + 'max_points': 0, + }, + ) + GlobalLeaderboardMultiplier.objects.get_or_create( + contribution_type=waitlist_type, + defaults={ + 'multiplier_value': 1.0, + 'valid_from': timezone.now() - timezone.timedelta(days=1), + }, + ) + + resp = self.client.post('/api/v1/users/start_validator_journey/') + + self.assertEqual(resp.status_code, status.HTTP_201_CREATED) + self.assertTrue( + Contribution.objects.filter( + user=self.user, + contribution_type__slug='validator-welcome', + points=0, + ).exists() + ) + self.assertTrue( + Contribution.objects.filter( + user=self.user, + contribution_type__slug='validator-waitlist', + points=0, + ).exists() + ) + + def test_complete_requires_starring_the_boilerplate(self): + response = self.client.post('/api/v1/users/complete_builder_journey/') + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(hasattr(self.user, 'builder')) + + def test_complete_grants_role_point_free(self): + self.complete_star_task() + + response = self.client.post('/api/v1/users/complete_builder_journey/') + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.user.refresh_from_db() + self.assertTrue(hasattr(self.user, 'builder')) + # Role grant creates NO contribution and awards NO points. + self.assertFalse( + Contribution.objects.filter(user=self.user, contribution_type__slug='builder').exists() + ) + self.assertEqual( + Contribution.objects.filter(user=self.user).count(), 0 + ) + + def test_complete_is_idempotent(self): + self.complete_star_task() + + first = self.client.post('/api/v1/users/complete_builder_journey/') + second = self.client.post('/api/v1/users/complete_builder_journey/') + + self.assertEqual(first.status_code, status.HTTP_201_CREATED) + self.assertEqual(second.status_code, status.HTTP_200_OK) + from builders.models import Builder + self.assertEqual(Builder.objects.filter(user=self.user).count(), 1) diff --git a/backend/users/tests/test_community_journey.py b/backend/users/tests/test_community_journey.py new file mode 100644 index 00000000..b16dca76 --- /dev/null +++ b/backend/users/tests/test_community_journey.py @@ -0,0 +1,268 @@ +"""Community journey: 5 steps -> Creator role. Step 5 (X post) verified via Sorsa.""" + +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APIClient + +from contributions.models import Category, Contribution, ContributionType +from creators import community_journey as cj +from creators.models import Creator, CommunityPostProof +from leaderboard.models import GlobalLeaderboardMultiplier +from social_connections.models import TwitterConnection +from social_tasks.models import SocialTask, SocialTaskCompletion +from social_tasks.sorsa_client import SorsaError + +User = get_user_model() + +POST_URL = 'https://x.com/social_user/status/1790000000000000000' + + +class CommunityJourneyTests(TestCase): + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user( + email='community@test.com', + address='0x' + '1' * 40, + password='testpass123', + ) + self.client.force_authenticate(user=self.user) + self.community, _ = Category.objects.get_or_create( + slug='community', defaults={'name': 'Community', 'description': 'c'}, + ) + for slug, name in [('community-link-x', 'Link X'), ('community-link-discord', 'Link Discord')]: + ContributionType.objects.update_or_create( + slug=slug, + defaults={'name': name, 'category': self.community, + 'min_points': 500, 'max_points': 500, 'is_submittable': False}, + ) + # These are seeded by social_tasks migration 0001; reuse them. + self.follow_task, _ = SocialTask.objects.get_or_create( + slug='follow-genlayer-x', + defaults={'name': 'Follow', 'category': self.community, 'points': 500, + 'verification_type': 'twitter_follow', 'target_handle': 'genlayer', + 'action_url': 'https://x.com/intent/follow?screen_name=genlayer'}, + ) + self.join_task, _ = SocialTask.objects.get_or_create( + slug='join-genlayer-discord', + defaults={'name': 'Join Discord', 'category': self.community, 'points': 500, + 'verification_type': 'discord_guild_join', 'action_url': 'https://discord.gg/genlayer'}, + ) + + # --- helpers --- + def link_x(self, handle='social_user'): + return TwitterConnection.objects.create( + user=self.user, platform_user_id='tw1', platform_username=handle, linked_at=timezone.now(), + ) + + def mark_link(self, slug): + ct = ContributionType.objects.get(slug=slug) + GlobalLeaderboardMultiplier.objects.get_or_create( + contribution_type=ct, + defaults={'multiplier_value': 1.0, 'valid_from': timezone.now() - timezone.timedelta(days=1)}, + ) + Contribution.objects.create( + user=self.user, contribution_type=ct, points=ct.min_points, contribution_date=timezone.now(), + ) + + def mark_task(self, task): + SocialTaskCompletion.objects.create( + user=self.user, task=task, points_awarded=task.points, verification_type=task.verification_type, + ) + + def start_journey(self): + return self.client.post('/api/v1/users/start_role_journey/', {'role': 'community'}) + + def complete_steps_1_to_4(self): + self.mark_link('community-link-x') + self.mark_link('community-link-discord') + self.mark_task(self.follow_task) + self.mark_task(self.join_task) + + def good_tweet(self): + return {'full_text': f'Joining the @{cj.genlayer_handle()} community! {cj.verification_code(self.user)}', + 'username': 'social_user'} + + # --- status --- + def test_status_reflects_step_completion(self): + res = self.client.get('/api/v1/users/community_journey/') + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertFalse(res.data['started']) + steps = res.data['steps'] + self.assertFalse(any(s['done'] for s in steps.values())) + self.assertFalse(res.data['complete']) + # step 5 ships the code + share/intent affordances + self.assertTrue(steps['x_post']['verification_code'].startswith('GL-')) + self.assertIn('intent/post', steps['x_post']['intent_url']) + + self.start_journey() + self.complete_steps_1_to_4() + res = self.client.get('/api/v1/users/community_journey/') + s = res.data['steps'] + self.assertTrue(res.data['started']) + self.assertTrue(s['link_x']['done'] and s['link_discord']['done'] + and s['follow_x']['done'] and s['join_discord']['done']) + self.assertFalse(s['x_post']['done']) + self.assertFalse(res.data['complete']) + + def test_start_journey_creates_marker_without_creator(self): + res = self.start_journey() + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + marker = Contribution.objects.get(user=self.user, contribution_type__slug='community-welcome') + self.assertEqual(marker.points, 0) + self.assertFalse(Creator.objects.filter(user=self.user).exists()) + + def test_community_contribution_does_not_auto_create_creator(self): + self.mark_link('community-link-x') + quest_type = ContributionType.objects.create( + slug='community-quest', + name='Community Quest', + category=self.community, + min_points=1, + max_points=100, + is_submittable=True, + ) + GlobalLeaderboardMultiplier.objects.create( + contribution_type=quest_type, + multiplier_value=1.0, + valid_from=timezone.now() - timezone.timedelta(days=1), + ) + Contribution.objects.create( + user=self.user, + contribution_type=quest_type, + points=25, + contribution_date=timezone.now(), + ) + + self.assertFalse(Creator.objects.filter(user=self.user).exists()) + + # --- verify post (step 5) --- + @patch('social_tasks.sorsa_client.SorsaClient.get_tweet') + def test_verify_post_success(self, mock_get_tweet): + self.link_x('social_user') + mock_get_tweet.return_value = self.good_tweet() + res = self.client.post('/api/v1/users/verify_community_post/', {'post_url': POST_URL}) + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertTrue(CommunityPostProof.objects.filter(user=self.user).exists()) + self.assertTrue(res.data['journey']['steps']['x_post']['done']) + + def test_verify_post_requires_linked_x(self): + res = self.client.post('/api/v1/users/verify_community_post/', {'post_url': POST_URL}) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(res.data['error'], 'x_not_linked') + + def test_verify_post_invalid_url(self): + self.link_x() + res = self.client.post('/api/v1/users/verify_community_post/', {'post_url': 'https://example.com/foo'}) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(res.data['error'], 'invalid_url') + + def test_verify_post_url_handle_mismatch(self): + self.link_x('social_user') + res = self.client.post('/api/v1/users/verify_community_post/', + {'post_url': 'https://x.com/someone_else/status/1790000000000000000'}) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(res.data['error'], 'account_mismatch') + + @patch('social_tasks.sorsa_client.SorsaClient.get_tweet') + def test_verify_post_code_missing(self, mock_get_tweet): + self.link_x('social_user') + mock_get_tweet.return_value = {'full_text': f'Joining the @{cj.genlayer_handle()} community!', 'username': 'social_user'} + res = self.client.post('/api/v1/users/verify_community_post/', {'post_url': POST_URL}) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(res.data['error'], 'code_missing') + + @patch('social_tasks.sorsa_client.SorsaClient.get_tweet') + def test_verify_post_tag_missing(self, mock_get_tweet): + self.link_x('social_user') + mock_get_tweet.return_value = {'full_text': f'Joining! {cj.verification_code(self.user)}', 'username': 'social_user'} + res = self.client.post('/api/v1/users/verify_community_post/', {'post_url': POST_URL}) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(res.data['error'], 'tag_missing') + + @patch('social_tasks.sorsa_client.SorsaClient.get_tweet') + def test_verify_post_author_mismatch(self, mock_get_tweet): + self.link_x('social_user') + mock_get_tweet.return_value = {'full_text': f'@{cj.genlayer_handle()} {cj.verification_code(self.user)}', 'username': 'imposter'} + res = self.client.post('/api/v1/users/verify_community_post/', {'post_url': POST_URL}) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(res.data['error'], 'account_mismatch') + + @patch('social_tasks.sorsa_client.SorsaClient.get_tweet') + def test_verify_post_not_found(self, mock_get_tweet): + self.link_x('social_user') + mock_get_tweet.return_value = None + res = self.client.post('/api/v1/users/verify_community_post/', {'post_url': POST_URL}) + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(res.data['error'], 'post_not_found') + + @patch('social_tasks.sorsa_client.SorsaClient.get_tweet') + def test_verify_post_sorsa_unavailable(self, mock_get_tweet): + self.link_x('social_user') + mock_get_tweet.side_effect = SorsaError('down') + res = self.client.post('/api/v1/users/verify_community_post/', {'post_url': POST_URL}) + self.assertEqual(res.status_code, status.HTTP_503_SERVICE_UNAVAILABLE) + self.assertEqual(res.data['error'], 'verification_unavailable') + + # --- complete --- + def test_complete_requires_started_even_if_steps_done(self): + self.complete_steps_1_to_4() + CommunityPostProof.objects.create(user=self.user, post_url=POST_URL, tweet_id='1790000000000000000') + res = self.client.post('/api/v1/users/complete_community_journey/') + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(res.data['error'], 'not_started') + self.assertFalse(Creator.objects.filter(user=self.user).exists()) + + def test_complete_requires_all_steps(self): + self.start_journey() + self.complete_steps_1_to_4() # step 5 missing + res = self.client.post('/api/v1/users/complete_community_journey/') + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(res.data['missing_steps'], ['x_post']) + self.assertFalse(Creator.objects.filter(user=self.user).exists()) + + def test_complete_grants_creator(self): + self.start_journey() + self.complete_steps_1_to_4() + CommunityPostProof.objects.create(user=self.user, post_url=POST_URL, tweet_id='1790000000000000000') + res = self.client.post('/api/v1/users/complete_community_journey/') + self.assertEqual(res.status_code, status.HTTP_201_CREATED) + self.assertTrue(Creator.objects.filter(user=self.user).exists()) + + def test_complete_is_idempotent(self): + self.start_journey() + self.complete_steps_1_to_4() + CommunityPostProof.objects.create(user=self.user, post_url=POST_URL, tweet_id='1790000000000000000') + first = self.client.post('/api/v1/users/complete_community_journey/') + second = self.client.post('/api/v1/users/complete_community_journey/') + self.assertEqual(first.status_code, status.HTTP_201_CREATED) + self.assertEqual(second.status_code, status.HTTP_200_OK) + self.assertEqual(Creator.objects.filter(user=self.user).count(), 1) + + def test_existing_creator_is_grandfathered(self): + # A pre-existing community member (Creator) who never went through the + # new journey is grandfathered in: treated as a complete member and + # never re-funneled through the steps. Journeys only apply to newcomers. + Creator.objects.create(user=self.user) + status_res = self.client.get('/api/v1/users/community_journey/') + self.assertTrue(status_res.data['is_member']) + self.assertTrue(status_res.data['complete']) + self.assertTrue(status_res.data['started']) + res = self.client.post('/api/v1/users/complete_community_journey/') + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertIn('already a community member', res.data['message']) + + def test_legacy_creator_join_requires_completed_journey(self): + res = self.client.post('/api/v1/creators/join/') + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(res.data['error'], 'not_started') + self.assertFalse(Creator.objects.filter(user=self.user).exists()) + + self.start_journey() + res = self.client.post('/api/v1/creators/join/') + self.assertEqual(res.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(res.data['error'], 'incomplete') + self.assertFalse(Creator.objects.filter(user=self.user).exists()) diff --git a/backend/users/tests/test_social_link_rewards.py b/backend/users/tests/test_social_link_rewards.py index 70ea6276..0abe1e57 100644 --- a/backend/users/tests/test_social_link_rewards.py +++ b/backend/users/tests/test_social_link_rewards.py @@ -5,7 +5,7 @@ from rest_framework.test import APIClient from contributions.models import Category, Contribution, ContributionType -from social_connections.models import DiscordConnection, TwitterConnection +from social_connections.models import DiscordConnection, GitHubConnection, TwitterConnection User = get_user_model() @@ -26,6 +26,13 @@ def setUp(self): 'description': 'Community contributions', }, ) + self.builder, _ = Category.objects.get_or_create( + slug='builder', + defaults={ + 'name': 'Builder', + 'description': 'Builder contributions', + }, + ) self.link_x_type, _ = ContributionType.objects.update_or_create( slug='community-link-x', defaults={ @@ -48,6 +55,25 @@ def setUp(self): 'is_submittable': False, }, ) + self.link_github_type, _ = ContributionType.objects.update_or_create( + slug='community-link-github', + defaults={ + 'name': 'Link GitHub Account', + 'description': 'Linked GitHub account', + 'category': self.builder, + 'min_points': 25, + 'max_points': 25, + 'is_submittable': False, + }, + ) + + def link_github(self): + return GitHubConnection.objects.create( + user=self.user, + platform_user_id='gh-123', + platform_username='social_user', + linked_at=timezone.now(), + ) def link_twitter(self): return TwitterConnection.objects.create( @@ -90,6 +116,40 @@ def test_link_discord_account_awards_configured_points(self): self.assertEqual(contribution.points, 500) self.assertEqual(contribution.frozen_global_points, 500) + def test_link_github_account_awards_configured_points(self): + self.link_github() + + response = self.client.post('/api/v1/users/link_github_account/') + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data['message'], 'GitHub account linked successfully! 25 points awarded.') + contribution = Contribution.objects.get(user=self.user, contribution_type=self.link_github_type) + self.assertEqual(contribution.points, 25) + self.assertEqual(contribution.frozen_global_points, 25) + + def test_link_github_account_requires_github_connection(self): + response = self.client.post('/api/v1/users/link_github_account/') + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data['error'], 'You must link your GitHub account first') + + def test_link_github_account_is_idempotent(self): + self.link_github() + + first_response = self.client.post('/api/v1/users/link_github_account/') + second_response = self.client.post('/api/v1/users/link_github_account/') + + self.assertEqual(first_response.status_code, status.HTTP_201_CREATED) + self.assertEqual(second_response.status_code, status.HTTP_200_OK) + self.assertEqual( + second_response.data['message'], + 'You already earned points for linking your GitHub account', + ) + self.assertEqual( + Contribution.objects.filter(user=self.user, contribution_type=self.link_github_type).count(), + 1, + ) + def test_link_x_account_requires_twitter_connection(self): response = self.client.post('/api/v1/users/link_x_account/') diff --git a/backend/users/views.py b/backend/users/views.py index fde39179..c60bbee1 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -399,68 +399,99 @@ def _get_web3_contract(self): # Create contract instance return w3.eth.contract(address=contract_address, abi=abi) - @action(detail=False, methods=['post'], permission_classes=[IsAuthenticated]) - def start_builder_journey(self, request): - """ - Award the builder welcome contribution to start the builder journey. - This gives the user their first contribution without checking other requirements. + def _mark_journey_started(self, request, role): + """Mark a role journey as started with a 0-point `-welcome` marker. + + Point-free (not farmable) and idempotent. `role` in {builder, validator, + community}. The marker persists "journey started" across sessions; points + come only from the journey's verifiable tasks. This is the lightweight + "entered the journey" signal, distinct from a role's deeper commitments + (validator waitlist, becoming a creator), which keep their own rows. """ - from contributions.models import Contribution, ContributionType + from contributions.models import Contribution, ContributionType, Category from django.utils import timezone - from django.db import transaction - - user = request.user - - # Check if user already has the contribution - try: - welcome_type = ContributionType.objects.get(slug='builder-welcome') - except ContributionType.DoesNotExist: - return Response( - {'error': 'Builder welcome contribution type not configured'}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) - - if Contribution.objects.filter(user=user, contribution_type=welcome_type).exists(): + + role_welcome = { + 'builder': ('builder-welcome', 'builder', 'Builder Welcome'), + 'validator': ('validator-welcome', 'validator', 'Validator Welcome'), + 'community': ('community-welcome', 'community', 'Community Welcome'), + } + if role not in role_welcome: return Response( - {'message': 'You already have the builder welcome contribution'}, - status=status.HTTP_200_OK + {'error': "role must be one of: builder, validator, community"}, + status=status.HTTP_400_BAD_REQUEST, ) - - # Create the contribution to start the journey - try: - with transaction.atomic(): - # Ensure multiplier exists for builder-welcome contribution type - from leaderboard.models import GlobalLeaderboardMultiplier - if not GlobalLeaderboardMultiplier.objects.filter(contribution_type=welcome_type).exists(): - GlobalLeaderboardMultiplier.objects.create( - contribution_type=welcome_type, - multiplier_value=1.0, - valid_from=timezone.now() - timezone.timedelta(days=30), - description='Default multiplier for Builder Welcome contributions', - notes='Applied when users start the builder journey' - ) - - contribution = Contribution.objects.create( - user=user, + + slug, category_slug, name = role_welcome[role] + user = request.user + category = Category.objects.filter(slug=category_slug).first() + welcome_type, _ = ContributionType.objects.get_or_create( + slug=slug, + defaults={ + 'name': name, + 'description': f'Started the {role} journey', + 'category': category, + 'is_submittable': False, + 'min_points': 0, + 'max_points': 0, + }, + ) + + from django.db import transaction + from django.contrib.auth import get_user_model + from leaderboard.models import GlobalLeaderboardMultiplier + + with transaction.atomic(): + # Lock the user row to serialize concurrent requests + get_user_model().objects.select_for_update().get(pk=user.pk) + + if Contribution.objects.filter(user=user, contribution_type=welcome_type).exists(): + serializer = self.get_serializer(user) + return Response({ + 'message': f'{role} journey already started', + 'user': serializer.data + }, status=status.HTTP_200_OK) + + # Contribution.clean() requires an active multiplier for the type, even + # for a 0-point marker. Ensure one exists (value is irrelevant at 0 pts). + # Lock the shared type row so concurrent requests for DIFFERENT users + # can't both pass the exists() check and duplicate the default row. + ContributionType.objects.select_for_update().get(pk=welcome_type.pk) + if not GlobalLeaderboardMultiplier.objects.filter(contribution_type=welcome_type).exists(): + GlobalLeaderboardMultiplier.objects.create( contribution_type=welcome_type, - points=20, - contribution_date=timezone.now(), - notes='Started builder journey - welcome to the GenLayer community!' + multiplier_value=1.0, + valid_from=timezone.now() - timezone.timedelta(days=30), + description=f'Default multiplier for {name} (0-point started marker)', + notes='Applied when users start a role journey', ) - - serializer = self.get_serializer(user) - return Response({ - 'message': 'Builder journey started successfully!', - 'user': serializer.data - }, status=status.HTTP_201_CREATED) - - except Exception as e: - logger.error(f"Failed to start builder journey: {str(e)}") - return Response( - {'error': f'Failed to start journey: {str(e)}'}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR + + # ponytail: 0-point marker only — "started", not a reward. + Contribution.objects.create( + user=user, + contribution_type=welcome_type, + points=0, + contribution_date=timezone.now(), + notes=f'Started the {role} journey', ) - + + serializer = self.get_serializer(user) + return Response({ + 'message': f'{role} journey started', + 'user': serializer.data + }, status=status.HTTP_201_CREATED) + + @action(detail=False, methods=['post'], permission_classes=[IsAuthenticated]) + def start_builder_journey(self, request): + """Mark the builder journey as started, point-free. See _mark_journey_started.""" + return self._mark_journey_started(request, 'builder') + + @action(detail=False, methods=['post'], permission_classes=[IsAuthenticated]) + def start_role_journey(self, request): + """Mark any role journey as started, point-free. `role` in {builder, validator, community}.""" + role = (request.data.get('role') or '').strip().lower() + return self._mark_journey_started(request, role) + @action(detail=False, methods=['post'], permission_classes=[IsAuthenticated]) def start_validator_journey(self, request): """ @@ -469,10 +500,14 @@ def start_validator_journey(self, request): """ from contributions.models import Contribution, ContributionType from django.utils import timezone - + from django.db import transaction + user = request.user - - # Check if user already has the contribution + + # Preflight the waitlist type BEFORE marking the journey started, so a + # misconfigured type can't leave the user "started" without the single + # waitlist step. The marker and the waitlist contribution then share one + # transaction, so if either write fails the whole start rolls back. try: waitlist_type = ContributionType.objects.get(slug='validator-waitlist') except ContributionType.DoesNotExist: @@ -480,178 +515,104 @@ def start_validator_journey(self, request): {'error': 'Validator waitlist contribution type not configured'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) - - if Contribution.objects.filter(user=user, contribution_type=waitlist_type).exists(): - return Response( - {'message': 'You already have the validator waitlist contribution'}, - status=status.HTTP_200_OK - ) - - # Create the contribution to start the journey - try: - contribution = Contribution.objects.create( - user=user, - contribution_type=waitlist_type, - points=0, - contribution_date=timezone.now(), - notes='Joined the validator waitlist' - ) - - serializer = self.get_serializer(user) - return Response({ - 'message': 'Validator journey started successfully!', - 'user': serializer.data - }, status=status.HTTP_201_CREATED) - - except Exception as e: - return Response( - {'error': f'Failed to start journey: {str(e)}'}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) + + with transaction.atomic(): + started_response = self._mark_journey_started(request, 'validator') + if started_response.status_code >= 400: + return started_response + + if not Contribution.objects.filter(user=user, contribution_type=waitlist_type).exists(): + Contribution.objects.create( + user=user, + contribution_type=waitlist_type, + points=0, + contribution_date=timezone.now(), + notes='Joined the validator waitlist' + ) + + serializer = self.get_serializer(user) + return Response({ + 'message': 'Validator journey started successfully!', + 'user': serializer.data + }, status=status.HTTP_201_CREATED) @action(detail=False, methods=['post'], permission_classes=[IsAuthenticated]) def complete_builder_journey(self, request): """ - Check if user meets builder journey requirements and award the builder contribution. - Requirements: - 1. Has at least one contribution (any type) - 2. Has testnet balance > 0 + Grant the Builder role once the journey is done. + + The journey is POINT-FREE: becoming a Builder awards no points. The old + `builder` (+50) contribution was self-serve and farmable, so it was + removed. Points come only from the verifiable task inside the journey + (starring the boilerplate repo, a builder-category social task). - Also creates Builder profile if it doesn't exist. + Requirement: the user has completed the boilerplate-star social task + (`settings.BUILDER_JOURNEY_TASK_SLUG`), which itself requires a linked + GitHub account and a real star. That single check subsumes "connect + GitHub and star the repo". """ - from contributions.models import Contribution, ContributionType + from django.conf import settings from builders.models import Builder - from django.utils import timezone - from django.db import transaction - from web3 import Web3 - import requests - + from social_tasks.models import SocialTaskCompletion + user = request.user - - # Check if user already has the BUILDER contribution (not builder-welcome) - try: - builder_type = ContributionType.objects.get(slug='builder') - except ContributionType.DoesNotExist: - # If builder contribution type doesn't exist, create it - from contributions.models import Category - try: - builder_category = Category.objects.get(slug='builder') - except Category.DoesNotExist: - builder_category = None - - builder_type = ContributionType.objects.create( - name='Builder', - slug='builder', - description='Awarded when becoming a GenLayer Builder', - category=builder_category - ) - - if Contribution.objects.filter(user=user, contribution_type=builder_type).exists(): - # User already has the builder contribution, just ensure they have a Builder profile - if not hasattr(user, 'builder'): - Builder.objects.create(user=user) - + + # Idempotent: already a Builder. + if hasattr(user, 'builder'): serializer = self.get_serializer(user) return Response({ 'message': 'You are already a GenLayer Builder', 'user': serializer.data }, status=status.HTTP_200_OK) - - # Check requirement 1: Has at least one contribution - has_contribution = Contribution.objects.filter(user=user).exists() - if not has_contribution: - return Response( - {'error': 'You need at least one contribution to complete the builder journey'}, - status=status.HTTP_400_BAD_REQUEST - ) - - # Check requirement 2: Has testnet balance > 0 - if not user.address: + + # ponytail: role gate is the boilerplate-star task by slug. Renaming or + # deactivating that task blocks completion until it's restored. + starred = SocialTaskCompletion.objects.filter( + user=user, task__slug=settings.BUILDER_JOURNEY_TASK_SLUG + ).exists() + if not starred: return Response( - {'error': 'No wallet address associated with your account'}, + {'error': 'Finish the builder journey first: connect GitHub and star the boilerplate repository.'}, status=status.HTTP_400_BAD_REQUEST ) - - try: - # Check testnet balance using Web3 with RPC URL from settings - from django.conf import settings - web3 = Web3(Web3.HTTPProvider(settings.VALIDATOR_RPC_URL)) - checksum_address = Web3.to_checksum_address(user.address) - with trace_external('web3', 'get_balance'): - balance_wei = web3.eth.get_balance(checksum_address) - balance_eth = web3.from_wei(balance_wei, 'ether') - - if balance_eth <= 0: - return Response( - {'error': 'You need testnet tokens to complete the builder journey. Visit the faucet to get tokens.'}, - status=status.HTTP_400_BAD_REQUEST - ) - except Exception as e: - logger.warning(f"Failed to check balance: {str(e)}") - # Fail closed: completing the journey awards points, so an RPC - # outage must not become a bypass of the balance requirement. - return Response( - {'error': 'Unable to verify your testnet balance right now. Please try again in a few minutes.'}, - status=status.HTTP_503_SERVICE_UNAVAILABLE - ) - - # All requirements met, create the BUILDER contribution and Builder profile atomically + try: + from django.db import transaction + from django.contrib.auth import get_user_model + with transaction.atomic(): - # First ensure the multiplier exists for the builder contribution type - from leaderboard.models import GlobalLeaderboardMultiplier - if not GlobalLeaderboardMultiplier.objects.filter(contribution_type=builder_type).exists(): - # Create a default multiplier if it doesn't exist - GlobalLeaderboardMultiplier.objects.create( - contribution_type=builder_type, - multiplier_value=1.0, - valid_from=timezone.now() - timezone.timedelta(days=30), - description='Default multiplier for Builder contributions', - notes='Applied when users complete the builder journey' - ) - - # Create the BUILDER contribution (this is the actual achievement) - contribution = Contribution.objects.create( - user=user, - contribution_type=builder_type, - points=50, # Points for becoming a builder - contribution_date=timezone.now(), - notes='Became a GenLayer Builder - completed all requirements' - ) - - # Create Builder profile if it doesn't exist - builder_created = False - if not hasattr(user, 'builder'): - Builder.objects.create(user=user) - builder_created = True - - # Ensure leaderboard is updated with fresh user state after transaction commits. - # The post_save signal on Contribution fires before the Builder profile exists, - # so we explicitly recalculate here with a fresh user that has the Builder relation. - from leaderboard.models import update_user_leaderboard_entries - fresh_user = type(user).objects.get(pk=user.pk) - update_user_leaderboard_entries(fresh_user) + # Lock the user row so two concurrent requests can't both pass + # the hasattr check above and race into a OneToOne IntegrityError. + get_user_model().objects.select_for_update().get(pk=user.pk) + _, created = Builder.objects.get_or_create(user=user) + + if created: + # Recalculate leaderboard entries now that the Builder relation + # exists. The grant itself adds no points, but builder-category + # aggregation keys off the Builder profile being present. + from leaderboard.models import update_user_leaderboard_entries + fresh_user = type(user).objects.get(pk=user.pk) + update_user_leaderboard_entries(fresh_user) - # Transaction successful, return response serializer = self.get_serializer(user) return Response({ - 'message': 'Builder journey completed successfully!', + 'message': 'Welcome to GenLayer Builders!', 'user': serializer.data }, status=status.HTTP_201_CREATED) - + except Exception as e: - # Transaction will be rolled back automatically logger.error(f"Failed to complete builder journey: {str(e)}") return Response( {'error': f'Failed to complete journey: {str(e)}'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) - - @action(detail=False, methods=['post'], permission_classes=[IsAuthenticated]) - def link_x_account(self, request): - """ - Award points for linking an X (Twitter) account. - Requires that the user has a verified TwitterConnection via OAuth. + + def _award_social_link_points(self, request, *, slug, connection_attr, label, oauth_label): + """Award the configured points for linking a social account. + + Idempotent, and locks the user row so concurrent calls can't double-award. + `label` is the display name (X / Discord / GitHub); `oauth_label` is the + name shown in the connect prompt (X uses "X (Twitter)"). """ from contributions.models import Contribution, ContributionType from django.utils import timezone @@ -660,16 +621,16 @@ def link_x_account(self, request): user = request.user try: - link_type = ContributionType.objects.get(slug='community-link-x') + link_type = ContributionType.objects.get(slug=slug) except ContributionType.DoesNotExist: return Response( - {'error': 'Community link X contribution type not configured'}, + {'error': f'Community link {label} contribution type not configured'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) - if not hasattr(user, 'twitterconnection'): + if not hasattr(user, connection_attr): return Response( - {'error': 'You must link your X (Twitter) account first'}, + {'error': f'You must link your {oauth_label} account first'}, status=status.HTTP_400_BAD_REQUEST ) @@ -681,18 +642,21 @@ def link_x_account(self, request): if Contribution.objects.filter(user=user, contribution_type=link_type).exists(): return Response( - {'message': 'You already earned points for linking your X account'}, + {'message': f'You already earned points for linking your {label} account'}, status=status.HTTP_200_OK ) from leaderboard.models import GlobalLeaderboardMultiplier + # Lock the shared type row so concurrent requests for DIFFERENT users + # can't both pass the exists() check and duplicate the default row. + ContributionType.objects.select_for_update().get(pk=link_type.pk) if not GlobalLeaderboardMultiplier.objects.filter(contribution_type=link_type).exists(): GlobalLeaderboardMultiplier.objects.create( contribution_type=link_type, multiplier_value=1.0, valid_from=timezone.now() - timezone.timedelta(days=30), - description='Default multiplier for Community Link X contributions', - notes='Applied when users link their X account' + description=f'Default multiplier for Community Link {label} contributions', + notes=f'Applied when users link their {label} account' ) Contribution.objects.create( @@ -700,91 +664,218 @@ def link_x_account(self, request): contribution_type=link_type, points=link_type.min_points, contribution_date=timezone.now(), - notes='Linked X (Twitter) account to GenLayer profile' + notes=f'Linked {oauth_label} account to GenLayer profile' ) serializer = self.get_serializer(user) return Response({ - 'message': f'X account linked successfully! {link_type.min_points} points awarded.', + 'message': f'{label} account linked successfully! {link_type.min_points} points awarded.', 'user': serializer.data }, status=status.HTTP_201_CREATED) except Exception as e: - logger.error(f"Failed to award X link points: {str(e)}") + logger.error(f"Failed to award {label} link points: {str(e)}") return Response( {'error': 'Failed to award points. Please try again later.'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR ) + @action(detail=False, methods=['post'], permission_classes=[IsAuthenticated]) + def link_x_account(self, request): + """Award points for linking an X (Twitter) account (verified via OAuth).""" + return self._award_social_link_points( + request, + slug='community-link-x', + connection_attr='twitterconnection', + label='X', + oauth_label='X (Twitter)', + ) + @action(detail=False, methods=['post'], permission_classes=[IsAuthenticated]) def link_discord_account(self, request): + """Award points for linking a Discord account (verified via OAuth).""" + return self._award_social_link_points( + request, + slug='community-link-discord', + connection_attr='discordconnection', + label='Discord', + oauth_label='Discord', + ) + + @action(detail=False, methods=['post'], permission_classes=[IsAuthenticated]) + def link_github_account(self, request): + """Award points for linking a GitHub account (verified via OAuth).""" + return self._award_social_link_points( + request, + slug='community-link-github', + connection_attr='githubconnection', + label='GitHub', + oauth_label='GitHub', + ) + + @action(detail=False, methods=['get'], permission_classes=[IsAuthenticated]) + def community_journey(self, request): + """Community journey status: the 5 steps + completion + membership.""" + from creators import community_journey as cj + return Response(cj.journey_status(request.user), status=status.HTTP_200_OK) + + @action(detail=False, methods=['post'], permission_classes=[IsAuthenticated]) + def verify_community_post(self, request): + """Step 5: verify the user's X post (tags GenLayer + contains their code). + + The post must be a well-formed X post URL from the user's linked X + account; we read its text via Sorsa /tweet-info and check the pattern. """ - Award points for linking a Discord account. - Requires that the user has a verified DiscordConnection via OAuth. - """ - from contributions.models import Contribution, ContributionType - from django.utils import timezone - from django.db import transaction + from creators import community_journey as cj + from creators.models import CommunityPostProof + from contributions.url_utils import get_user_social_handle, normalize_url + from social_tasks.sorsa_client import get_default_client, SorsaError user = request.user + post_url = (request.data.get('post_url') or '').strip() + if not post_url: + return Response( + {'error': 'missing_url', 'message': 'Provide the URL of your X post.'}, + status=status.HTTP_400_BAD_REQUEST, + ) - try: - link_type = ContributionType.objects.get(slug='community-link-discord') - except ContributionType.DoesNotExist: + # The post must come from the user's linked X account. + if not hasattr(user, 'twitterconnection'): return Response( - {'error': 'Community link Discord contribution type not configured'}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR + {'error': 'x_not_linked', 'message': 'Link your X account first.'}, + status=status.HTTP_400_BAD_REQUEST, ) - if not hasattr(user, 'discordconnection'): + post_url = normalize_url(post_url) + url_handle, tweet_id = cj.parse_x_post(post_url) + if not tweet_id: return Response( - {'error': 'You must link your Discord account first'}, - status=status.HTTP_400_BAD_REQUEST + {'error': 'invalid_url', 'message': 'That is not a valid X post URL.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + linked_handle = get_user_social_handle(user, 'twitter') + if not linked_handle: + # twitterconnection exists but carries no handle: cannot verify + # ownership, so fail closed rather than skip the check. + return Response( + {'error': 'x_not_linked', 'message': 'Reconnect your X account and try again.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + if url_handle != linked_handle: + return Response( + {'error': 'account_mismatch', 'message': 'The post must come from your linked X account.'}, + status=status.HTTP_400_BAD_REQUEST, ) try: - with transaction.atomic(): - # Lock the user row to serialize concurrent requests - from django.contrib.auth import get_user_model - get_user_model().objects.select_for_update().get(pk=user.pk) + tweet = get_default_client().get_tweet(tweet_id) + except SorsaError as exc: + logger.warning(f"Sorsa tweet-info failed: {exc}") + return Response( + {'error': 'verification_unavailable', 'message': 'Could not verify your post right now. Please try again shortly.'}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) - if Contribution.objects.filter(user=user, contribution_type=link_type).exists(): - return Response( - {'message': 'You already earned points for linking your Discord account'}, - status=status.HTTP_200_OK - ) + if tweet is None: + return Response( + {'error': 'post_not_found', 'message': 'We could not find that post. Make sure it is public and the URL is correct.'}, + status=status.HTTP_400_BAD_REQUEST, + ) - from leaderboard.models import GlobalLeaderboardMultiplier - if not GlobalLeaderboardMultiplier.objects.filter(contribution_type=link_type).exists(): - GlobalLeaderboardMultiplier.objects.create( - contribution_type=link_type, - multiplier_value=1.0, - valid_from=timezone.now() - timezone.timedelta(days=30), - description='Default multiplier for Community Link Discord contributions', - notes='Applied when users link their Discord account' - ) + # Authoritative author check (Sorsa) against the linked handle. Fail + # closed: an absent/empty author must not pass the check. + author = (tweet['username'] or '').lower() + if not author: + return Response( + {'error': 'verification_unavailable', 'message': 'Could not verify your post right now. Please try again shortly.'}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + if author != linked_handle: + return Response( + {'error': 'account_mismatch', 'message': 'The post must come from your linked X account.'}, + status=status.HTTP_400_BAD_REQUEST, + ) - Contribution.objects.create( - user=user, - contribution_type=link_type, - points=link_type.min_points, - contribution_date=timezone.now(), - notes='Linked Discord account to GenLayer profile' - ) + ok, error_code = cj.post_matches(tweet['full_text'], user) + if not ok: + message = ( + f"Your post must @mention @{cj.genlayer_handle()}." + if error_code == 'tag_missing' + else 'Your post must include your verification code exactly as shown.' + ) + return Response({'error': error_code, 'message': message}, status=status.HTTP_400_BAD_REQUEST) - serializer = self.get_serializer(user) - return Response({ - 'message': f'Discord account linked successfully! {link_type.min_points} points awarded.', - 'user': serializer.data - }, status=status.HTTP_201_CREATED) + CommunityPostProof.objects.update_or_create( + user=user, + defaults={'post_url': post_url, 'tweet_id': tweet_id}, + ) + return Response( + {'status': 'verified', 'journey': cj.journey_status(user)}, + status=status.HTTP_200_OK, + ) - except Exception as e: - logger.error(f"Failed to award Discord link points: {str(e)}") + @action(detail=False, methods=['post'], permission_classes=[IsAuthenticated]) + def complete_community_journey(self, request): + """Grant the Creator (community) role once all 5 journey steps are done. + Point-free (steps 1-4 keep their own points).""" + from creators import community_journey as cj + from creators.models import Creator + from leaderboard.models import update_user_leaderboard_entries + + user = request.user + + # Existing members are grandfathered in: the journey only applies to + # newcomers, so never funnel a Creator back through the steps. + if hasattr(user, 'creator'): return Response( - {'error': 'Failed to award points. Please try again later.'}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR + {'message': 'You are already a community member', 'user': self.get_serializer(user).data}, + status=status.HTTP_200_OK, + ) + + journey = cj.journey_status(user) + if not journey['started']: + return Response( + { + 'error': 'not_started', + 'missing_steps': ['start'], + 'message': 'Start the community journey first.', + }, + status=status.HTTP_400_BAD_REQUEST, + ) + if not journey['complete']: + return Response( + { + 'error': 'incomplete', + 'missing_steps': journey['missing_steps'], + 'message': 'Complete all community journey steps first.', + }, + status=status.HTTP_400_BAD_REQUEST, ) + from django.db import transaction + from django.contrib.auth import get_user_model + + with transaction.atomic(): + # Lock the user row so two concurrent requests can't both pass the + # hasattr check above and race into a OneToOne IntegrityError. + get_user_model().objects.select_for_update().get(pk=user.pk) + _, created = Creator.objects.get_or_create(user=user) + + if not created: + return Response( + {'message': 'You are already a community member', 'user': self.get_serializer(user).data}, + status=status.HTTP_200_OK, + ) + + fresh_user = type(user).objects.get(pk=user.pk) + update_user_leaderboard_entries(fresh_user) + + return Response( + {'message': 'Welcome to the GenLayer community!', 'user': self.get_serializer(fresh_user).data}, + status=status.HTTP_201_CREATED, + ) + @action(detail=False, methods=['get']) def validators(self, request): """ diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 5af48bb7..00268015 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -12,6 +12,8 @@ import { location } from 'svelte-spa-router'; import { setRouteMeta } from './lib/meta.js'; import { authState, verifyAuth } from './lib/auth.js'; + import { userStore } from './lib/userStore.js'; + import { hasEarnedRole, journeyPath, rolePath } from './lib/roleState.js'; import { installLinkInterceptor } from './lib/router.js'; // Early OAuth result detection — runs before routes mount. @@ -63,7 +65,6 @@ } import Overview from './routes/Overview.svelte'; - import Dashboard from './routes/Dashboard.svelte'; import Contributions from './routes/Contributions.svelte'; import AllContributions from './routes/AllContributions.svelte'; import Leaderboard from './routes/Leaderboard.svelte'; @@ -114,6 +115,9 @@ import GlobalDashboard from './components/GlobalDashboard.svelte'; import SystemAlerts from './components/portal/SystemAlerts.svelte'; import SocialTasks from './routes/SocialTasks.svelte'; + import RoleFunnel from './components/funnel/RoleFunnel.svelte'; + import BuilderJourney from './routes/BuilderJourney.svelte'; + import CommunityJourney from './routes/CommunityJourney.svelte'; async function requireAuthForRoute({ location, querystring }) { const state = authState.get(); @@ -157,6 +161,39 @@ conditions: [requireAuthForRoute], }); + // Role-gated subsection: must be authenticated AND hold the role, else bounce + // to the role's main route (the funnel) so the user can start there. Role + // membership (user.builder/validator/creator) is authoritative for all three + // categories: existing members are grandfathered, the journey gates newcomers. + function hasSubsectionAccess(user, category) { + return hasEarnedRole(user, category); + } + + function requireRoleForRoute(category) { + return async (detail) => { + const authed = await requireAuthForRoute(detail); + if (!authed) return false; + + let user = userStore.getUser(); + if (!user) { + try { user = await userStore.loadUser(); } catch { user = null; } + } + if (await hasSubsectionAccess(user, category)) return true; + + // Stale-navigation guard: only redirect if still on the guarded route. + const normalizePath = (value) => (value || '/').replace(/\/+$/, '') || '/'; + const guarded = `${normalizePath(detail.location)}${detail.querystring ? `?${detail.querystring}` : ''}`; + const here = `${normalizePath(window.location.pathname)}${window.location.search || ''}`; + if (here === guarded) replace(category === 'community' ? journeyPath(category) : rolePath(category)); + return false; + }; + } + + const roleGatedRoute = (component, category) => wrap({ + component, + conditions: [requireRoleForRoute(category)], + }); + // Define routes const routes = { @@ -170,41 +207,44 @@ '/leaderboard': Leaderboard, '/participants': Validators, '/referrals': protectedRoute(Referrals), - '/community': Dashboard, - '/community/contributions': protectedRoute(Contributions), - '/community/all-contributions': protectedRoute(AllContributions), + '/community': RoleFunnel, + '/community/journey': protectedRoute(CommunityJourney), + '/community/contributions': roleGatedRoute(Contributions, 'community'), + '/community/all-contributions': roleGatedRoute(AllContributions, 'community'), '/community/referrals': LegacyReferralRedirect, - '/community/leaderboard': Leaderboard, - '/community/poaps': CommunityPoaps, - '/community/poaps/recover': PoapRecovery, - '/community/poaps/:slug': PoapDetail, - '/community/tasks': protectedRoute(SocialTasks), - '/community/contribution/:id': protectedRoute(ContributionPreview), + '/community/leaderboard': roleGatedRoute(Leaderboard, 'community'), + '/community/poaps': roleGatedRoute(CommunityPoaps, 'community'), + '/community/poaps/recover': roleGatedRoute(PoapRecovery, 'community'), + '/community/poaps/:slug': roleGatedRoute(PoapDetail, 'community'), + '/community/tasks': roleGatedRoute(SocialTasks, 'community'), + '/community/contribution/:id': roleGatedRoute(ContributionPreview, 'community'), '/claim/poap/:token': PoapClaim, '/hackathon': Hackathon, '/hackathon-winners': HackathonWinners, '/referral-program': ReferralProgram, // Builders routes - '/builders': Dashboard, - '/builders/contributions': protectedRoute(Contributions), - '/builders/all-contributions': protectedRoute(AllContributions), + '/builders': RoleFunnel, + '/builders/journey': protectedRoute(BuilderJourney), + '/builders/contributions': roleGatedRoute(Contributions, 'builder'), + '/builders/all-contributions': roleGatedRoute(AllContributions, 'builder'), '/builders/leaderboard': Leaderboard, - '/builders/resources': Resources, + '/builders/resources': roleGatedRoute(Resources, 'builder'), '/builders/projects/:slug/edit': protectedRoute(ProjectPageEditor), '/builders/projects/:slug': ProjectDetail, - '/builders/tasks': protectedRoute(SocialTasks), + '/builders/tasks': roleGatedRoute(SocialTasks, 'builder'), '/builders/startup-requests/:id': StartupRequestDetail, // Validators routes - '/validators': Dashboard, - '/validators/contributions': protectedRoute(Contributions), - '/validators/all-contributions': protectedRoute(AllContributions), + '/validators': RoleFunnel, + '/validators/journey': ValidatorWaitlist, + '/validators/contributions': roleGatedRoute(Contributions, 'validator'), + '/validators/all-contributions': roleGatedRoute(AllContributions, 'validator'), '/validators/leaderboard': Leaderboard, - '/validators/tasks': protectedRoute(SocialTasks), - '/validators/participants': Validators, - '/validators/wall-of-shame': WallOfShame, + '/validators/tasks': roleGatedRoute(SocialTasks, 'validator'), + '/validators/participants': roleGatedRoute(Validators, 'validator'), + '/validators/wall-of-shame': roleGatedRoute(WallOfShame, 'validator'), '/validators/waitlist': protectedRoute(Waitlist), '/validators/waitlist/participants': protectedRoute(WaitlistParticipants), '/validators/waitlist/join': ValidatorWaitlist, @@ -217,12 +257,12 @@ '/contribution-type/:id': protectedRoute(ContributionTypeDetail), '/mission/:id': protectedRoute(MissionDetail), '/badge/:id': BadgeDetail, - '/submit-contribution': SubmitContribution, - '/my-submissions': MySubmissions, - '/contributions/:id': EditSubmission, + '/submit-contribution': protectedRoute(SubmitContribution), + '/my-submissions': protectedRoute(MySubmissions), + '/contributions/:id': protectedRoute(EditSubmission), '/metrics': Metrics, - '/profile': ProfileEdit, - '/notifications': Notifications, // Full notification feed (authenticated only; renders a signed-out state otherwise) + '/profile': protectedRoute(ProfileEdit), + '/notifications': protectedRoute(Notifications), // Full notification feed (authenticated only) // Steward routes '/stewards': StewardDashboard, @@ -448,9 +488,9 @@
-
+
-
+
option.value)); + let selectedRole = $state('community'); + let roleTouched = $state(false); + + function selectRole(value) { + selectedRole = value; + roleTouched = true; + } + // Determine if profile is incomplete let showGuard = $derived.by(() => { // Don't show while loading @@ -35,6 +54,21 @@ return needsName || needsEmail; }); + // Preselect the role from where the user started auth (captured at sign-in, + // before the post-login redirect), falling back to the current route. Holds + // until the user picks one. + $effect(() => { + if (showGuard && !roleTouched) { + let stored = null; + try { stored = sessionStorage.getItem('onboardingRole'); } catch {} + // The stored value is untrusted (may be stale/invalid) — only honor it + // when it's a known role, otherwise fall back to the current route. + selectedRole = ROLE_VALUES.has(stored) + ? stored + : roleForCategory(detectCategoryFromRoute($location)); + } + }); + // Pre-fill form fields when user data is available $effect(() => { const user = $userStore.user; @@ -89,8 +123,14 @@ // Reload user data to ensure we have the latest await userStore.loadUser(); - // Redirect first-time users to How it works page - push('/how-it-works'); + // Send first-time users to their selected role's main route, where the + // funnel offers "Start the journey". Guard against a stale/invalid role + // value so the redirect never falls back to '/'. + const targetRole = ROLE_VALUES.has(selectedRole) + ? selectedRole + : roleForCategory(detectCategoryFromRoute($location)); + try { sessionStorage.removeItem('onboardingRole'); } catch {} + push(rolePath(targetRole)); } catch (err) { // Handle field-specific errors from Django REST Framework if (err.response?.data) { @@ -185,6 +225,22 @@

We'll use this to send you important updates about your contributions

+
+ I'm here as a… +
+ {#each ROLE_OPTIONS as option} + + {/each} +
+
+
{/if} @@ -306,30 +327,42 @@
{ e.preventDefault(); navigate('/validators/contributions'); }} - class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { + onclick={(e) => { e.preventDefault(); openRoleSection('/validators/contributions', 'validator'); }} + class="flex items-center justify-between border-l-[1.5px] px-3 py-2 text-[14px] font-medium tracking-[0.28px] { isActive('/validators/contributions') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' - }" + } {isRoleLocked('validator') ? 'text-gray-400' : 'text-black'}" + title={isRoleLocked('validator') ? 'Become a validator to unlock' : ''} > - Contributions + Contributions + {#if isRoleLocked('validator')} + + {/if} { e.preventDefault(); navigate('/validators/participants'); }} - class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { + onclick={(e) => { e.preventDefault(); openRoleSection('/validators/participants', 'validator'); }} + class="flex items-center justify-between border-l-[1.5px] px-3 py-2 text-[14px] font-medium tracking-[0.28px] { isActive('/validators/participants') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' - }" + } {isRoleLocked('validator') ? 'text-gray-400' : 'text-black'}" + title={isRoleLocked('validator') ? 'Become a validator to unlock' : ''} > - Participants + Participants + {#if isRoleLocked('validator')} + + {/if} { e.preventDefault(); navigate('/validators/wall-of-shame'); }} - class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { + onclick={(e) => { e.preventDefault(); openRoleSection('/validators/wall-of-shame', 'validator'); }} + class="flex items-center justify-between border-l-[1.5px] px-3 py-2 text-[14px] font-medium tracking-[0.28px] { isActive('/validators/wall-of-shame') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' - }" + } {isRoleLocked('validator') ? 'text-gray-400' : 'text-black'}" + title={isRoleLocked('validator') ? 'Become a validator to unlock' : ''} > - Wall of Shame + Wall of Shame + {#if isRoleLocked('validator')} + + {/if}
{/if} @@ -359,21 +392,29 @@ {/if} @@ -707,21 +748,29 @@ {/if} @@ -744,30 +793,42 @@
{ e.preventDefault(); navigate('/validators/contributions'); }} - class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { + onclick={(e) => { e.preventDefault(); openRoleSection('/validators/contributions', 'validator'); }} + class="flex items-center justify-between border-l-[1.5px] px-3 py-2 text-[14px] font-medium tracking-[0.28px] { isActive('/validators/contributions') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' - }" + } {isRoleLocked('validator') ? 'text-gray-400' : 'text-black'}" + title={isRoleLocked('validator') ? 'Become a validator to unlock' : ''} > - Contributions + Contributions + {#if isRoleLocked('validator')} + + {/if} { e.preventDefault(); navigate('/validators/participants'); }} - class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { + onclick={(e) => { e.preventDefault(); openRoleSection('/validators/participants', 'validator'); }} + class="flex items-center justify-between border-l-[1.5px] px-3 py-2 text-[14px] font-medium tracking-[0.28px] { isActive('/validators/participants') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' - }" + } {isRoleLocked('validator') ? 'text-gray-400' : 'text-black'}" + title={isRoleLocked('validator') ? 'Become a validator to unlock' : ''} > - Participants + Participants + {#if isRoleLocked('validator')} + + {/if} { e.preventDefault(); navigate('/validators/wall-of-shame'); }} - class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] { + onclick={(e) => { e.preventDefault(); openRoleSection('/validators/wall-of-shame', 'validator'); }} + class="flex items-center justify-between border-l-[1.5px] px-3 py-2 text-[14px] font-medium tracking-[0.28px] { isActive('/validators/wall-of-shame') ? 'border-[#387DE8]' : 'border-[#f5f5f5]' - }" + } {isRoleLocked('validator') ? 'text-gray-400' : 'text-black'}" + title={isRoleLocked('validator') ? 'Become a validator to unlock' : ''} > - Wall of Shame + Wall of Shame + {#if isRoleLocked('validator')} + + {/if}
{/if} @@ -790,21 +851,29 @@ {/if} diff --git a/frontend/src/components/SocialLink.svelte b/frontend/src/components/SocialLink.svelte index 3c726f8a..d6d0144a 100644 --- a/frontend/src/components/SocialLink.svelte +++ b/frontend/src/components/SocialLink.svelte @@ -2,7 +2,7 @@ import { onMount, onDestroy } from 'svelte'; import { authState } from '../lib/auth'; import { API_BASE_URL } from '../lib/config.js'; - import { getCurrentUser, socialAPI } from '../lib/api'; + import { getCurrentUser, socialAPI, journeyAPI } from '../lib/api'; import { showSuccess, showError } from '../lib/toastStore'; let { @@ -114,9 +114,25 @@ if (success) { const userPromise = currentUser ? Promise.resolve(currentUser) : getCurrentUser(); - userPromise.then((resolvedUser) => { + userPromise.then(async (resolvedUser) => { + let finalUser = resolvedUser; + let rewardFailed = false; + // Linking GitHub counts as a contribution (like X / Discord). Award it + // here so it fires wherever GitHub is linked; idempotent + best-effort, + // so a failed reward never blocks the successful link. + if (platform === 'github' && !wasRefreshing) { + try { + await journeyAPI.linkGithubAccount(); + finalUser = await getCurrentUser(); + } catch { + // The OAuth link succeeded, but recording the contribution did not. + // Surface a retryable error so the user re-triggers the marker + // instead of seeing a false success. + rewardFailed = true; + } + } if (shouldToast) { - const connData = resolvedUser?.[`${platform}_connection`]; + const connData = finalUser?.[`${platform}_connection`]; const username = connData?.platform_username || ''; if (wasRefreshing) { const previousUsername = connection?.platform_username || ''; @@ -125,11 +141,13 @@ ? `${platformLabel} username updated to ${username}` : `${platformLabel} username is already up to date` ); + } else if (rewardFailed) { + showError('GitHub account linked, but we could not record the contribution. Please try again.'); } else { showSuccess(`${platformLabel} account linked successfully!${username ? ` (@${username})` : ''}`); } } - onLinked(resolvedUser); + onLinked(finalUser); isLinking = false; isRefreshing = false; }).catch(() => { diff --git a/frontend/src/components/WhatsNewDialog.svelte b/frontend/src/components/WhatsNewDialog.svelte index f456fc33..2ca11a19 100644 --- a/frontend/src/components/WhatsNewDialog.svelte +++ b/frontend/src/components/WhatsNewDialog.svelte @@ -3,6 +3,8 @@ import { push } from 'svelte-spa-router'; import WhatsNewAnnouncementSurface from './whats-new-announcement-surface.svelte'; import { authState } from '../lib/auth.js'; + import { userStore } from '../lib/userStore.js'; + import { hasAnyRoleOrJourney } from '../lib/roleState.js'; import { followNotificationLink } from '../lib/notificationUtils.js'; import { whatsNewStore } from '../lib/whatsNewStore.js'; import { CAUGHT_UP_GRADIENT, normalizeWhatsNewItem } from '../lib/whatsNewPresentation.js'; @@ -151,6 +153,7 @@ $effect(() => { const isAuthenticated = $authState.isAuthenticated; const address = $authState.address; + const user = $userStore.user; if (!isAuthenticated) { lastAuthKey = null; @@ -159,6 +162,15 @@ return; } + // Wait for the user object so we can tell a freshly-created account apart. + if (!user) return; + + // Don't auto-open What's New for a brand-new account still in onboarding — + // it renders behind the profile-completion modal. Only greet members who + // have engaged: earned a role or started a journey. Once they do, the effect + // re-runs (userStore changes) and it shows. + if (!hasAnyRoleOrJourney(user)) return; + const authKey = address || 'authenticated'; if (authKey === lastAuthKey) return; diff --git a/frontend/src/components/funnel/AuthenticatedRoleLanding.svelte b/frontend/src/components/funnel/AuthenticatedRoleLanding.svelte new file mode 100644 index 00000000..1bb70c6e --- /dev/null +++ b/frontend/src/components/funnel/AuthenticatedRoleLanding.svelte @@ -0,0 +1,355 @@ + + + + {config.label} | GenLayer Portal + + +
+
+

{config.eyebrow}

+

{config.title}

+

{body}

+ + {#if isWaitlisted} +
+ + Validator waitlist active +
+ +
+
+

Operator onboarding

+

Need to speak with the validator team?

+

+ If you operate validator infrastructure or need to share operator details, + contact the team with your wallet, organization, and relevant node experience. +

+
+ + Contact the team + +
+ {:else} + + {/if} + +
+
+ + diff --git a/frontend/src/components/funnel/BuilderLanding.svelte b/frontend/src/components/funnel/BuilderLanding.svelte new file mode 100644 index 00000000..d022419a --- /dev/null +++ b/frontend/src/components/funnel/BuilderLanding.svelte @@ -0,0 +1,863 @@ + + + + Builders | GenLayer Portal + + +
+
+
+

Build Contracts
That Can Think

+

+ Smart contracts execute. Intelligent Contracts reason: they read the web, + understand natural language, and resolve disputes no if/else ever could. +

+
+
+ + + Read the Docs + +
+

{ctaHint}

+
+
+ +
+ +
+

+ What you can build here that
you can't build anywhere else. +

+
+ {#each features as feature} +
+
+ +

{feature.title}

+
+

{feature.body}

+
+ {/each} +
+
+ +
+

+ Oracles verify facts.
+ GenLayer adjudicates meaning. +

+
+ +
+
+

An entire category, wide open.

+

+ Performance-based contracts, agent marketplaces, prediction markets, + parametric insurance, autonomous finance: every vertical on GenLayer is + unclaimed territory. The builders shipping today are defining the category, + not competing in it. +

+
+
+ {#each projects as project} + {project} + {/each} +
+
+ +
+
+

Don't just deploy. Earn.

+

+ Earn up to 20% of the fees from every transaction your project generates, + and win GenLayer Points for all your daily actions. On GenLayer, your app + is a revenue stream from day one. +

+
+
+ 20% + Of transaction fees, back to you +
+
+ +
+

A full stack behind you.

+
+ {#each stats as stat} +
+

{stat.title}

+

+ {stat.value}{#if stat.center}{stat.center}{/if}{#if stat.suffix}{stat.suffix}{/if} +

+

{stat.label}

+
+ {/each} +
+
+ +
+

Ship your first
Intelligent Contract.

+

Get your own builder journey: wallet, Github, first contribution. Tracked from day one.

+
+
+ + + Read the Docs + +
+
+
+
+ + diff --git a/frontend/src/components/funnel/CommunityLanding.svelte b/frontend/src/components/funnel/CommunityLanding.svelte new file mode 100644 index 00000000..6839bdfd --- /dev/null +++ b/frontend/src/components/funnel/CommunityLanding.svelte @@ -0,0 +1,653 @@ + + + + Community | GenLayer Portal + + +
+
+
+

Community

+

+100,000 Strong.
Building the Court of the Internet.

+

+ Join the community pushing the adjudication layer of the agentic economy + and get rewarded for every contribution. +

+
+
+ +
+

{ctaHint}

+
+
+ + +
+ +
+
+

This Isn't a Spectator Community

+

+ Test the protocol. Surface bugs. Create content. Grow the ecosystem. + On GenLayer, community work is tracked on the Portal and rewarded. +

+
+
+ {#each contributionCards as card} +
+
+ +

{card.title}

+
+

{card.body}

+
+ {/each} +
+
+ +
+

+ You're a contributor,
+ not an audience. +

+
+ +
+
+

Join the Community, Then Earn Through Tasks

+

+ Completing the journey grants you the Community role. From there, + specific verifiable tasks earn GenLayer Points, building your record of + helping the network from the earliest days. +

+
+
+ {#each pointsCards as card} +
+

{card.title}

+

{card.body}

+
+ {/each} +
+
+ +
+
+

The Missing Layer of the Internet Is Being Built in the Open

+

+ Mainnet is coming. The contributors of today are the foundation of the + network tomorrow. Position yourself in the next revolution of the internet, + the agentic economy. +

+
+
+ {#each stats as stat} +
+

+ {stat.value}{#if stat.suffix}{stat.suffix}{/if} +

+

{stat.label}

+
+ {/each} +
+
+ +
+

Join the community.

+

Complete the journey to unlock the Community role, then earn points through tasks and help grow the network from day one.

+ +
+
+ + diff --git a/frontend/src/components/funnel/RoleFunnel.svelte b/frontend/src/components/funnel/RoleFunnel.svelte new file mode 100644 index 00000000..e07bdb54 --- /dev/null +++ b/frontend/src/components/funnel/RoleFunnel.svelte @@ -0,0 +1,55 @@ + + +{#if isChecking || (isAuth && !user)} + +
+
+
+{:else if funnelState === 'earned'} + +{:else if funnelState === 'started'} + + {#if category === 'builder'} + + {:else if category === 'validator'} + {#if user?.['has_validator_waitlist']} + + + {:else} + + {/if} + {:else} + + {/if} +{:else} + +{/if} diff --git a/frontend/src/components/funnel/RoleLanding.svelte b/frontend/src/components/funnel/RoleLanding.svelte new file mode 100644 index 00000000..29794f9b --- /dev/null +++ b/frontend/src/components/funnel/RoleLanding.svelte @@ -0,0 +1,48 @@ + + +{#if roleState !== 'unauthenticated'} + handleStart(category)} /> +{:else if category === 'builder'} + handleStart('builder')} /> +{:else if category === 'validator'} + handleStart('validator')} /> +{:else} + handleStart('community')} /> +{/if} diff --git a/frontend/src/components/funnel/ValidatorLanding.svelte b/frontend/src/components/funnel/ValidatorLanding.svelte new file mode 100644 index 00000000..51e69274 --- /dev/null +++ b/frontend/src/components/funnel/ValidatorLanding.svelte @@ -0,0 +1,740 @@ + + + + Validators | GenLayer Portal + + +
+ {#snippet waitlistBadge()} +
+ + You're on the waitlist +
+ {/snippet} + +
+
+

Validators

+

Don't Validate Blocks.
Adjudicate the Agentic Economy.

+

+ GenLayer validators don't rubber-stamp transactions - they reason, + judge, and resolve real disputes. And they earn more for it. +

+
+
+ {#if isWaitlisted} + {@render waitlistBadge()} + {:else} + + {/if} +
+

Reason through real outcomes, participate in consensus, and earn for judgment.

+
+
+ + +
+ +
+
+

From Passive Infrastructure to Active Judgment

+

+ On GenLayer, each validator runs an AI model and participates in Optimistic Democracy - + proposing, verifying, and appealing real decisions. +

+
+
+ {#each comparisonCards as card} +
+
+ +

{card.title}

+
+ {card.value} +

{card.body}

+
+ {/each} +
+
+ +
+

+ Validators don't just secure GenLayer.
+ They adjudicate it. +

+
+ +
+
+

Higher Fees Per Transaction Than Anywhere Else

+

+ With gas enabled, GenLayer's current activity would already rank it Top-15 + among all chains by daily fees - ahead of Avalanche, Cardano, and Near. +

+
+
+ {#each economics as item} +
+ +

{item}

+
+ {/each} +
+
+ +
+

Thousands of decisions. Every day. Growing.

+
+ {#each proofStats as stat} +
+

+ {stat.value}{#if stat.suffix}{stat.suffix}{/if} +

+

{stat.label}

+
+ {/each} +
+
+ +
+
+

How It Works

+

+ Verifiable random selection chooses who proposes. Other validators verify. + Disagreement escalates through appeals until finality - in minutes. +

+
+
+ {#each workflow as step} + {step} + {/each} +
+
+ +
+

Run the node that reasons.

+

Join the validator path and prepare to resolve the agentic economy.

+ {#if isWaitlisted} + {@render waitlistBadge()} + {:else} + + {/if} +
+
+ + diff --git a/frontend/src/components/funnel/journeys/JourneyHeroCard.svelte b/frontend/src/components/funnel/journeys/JourneyHeroCard.svelte new file mode 100644 index 00000000..9f7b0af4 --- /dev/null +++ b/frontend/src/components/funnel/journeys/JourneyHeroCard.svelte @@ -0,0 +1,436 @@ + + +
+
+
+ {#if showKickerIcon} + + {/if} + +

{eyebrow}

+
+ +

+ {#if accentValue} + {accentValue}{titleRest} + {:else} + {titleRest} + {/if} + {#if iconPlacement === 'title'} + + {/if} +

+ +

{description}

+ + {#if showProgress} + + {/if} + +
+ {#if primaryLabel} + + {/if} + {#if helper} +

{helper}

+ {/if} +
+
+ + {#if heroContribution === 'icon'} + + {:else} +
+ {completed}/{total} +
+ {/if} +
+ + diff --git a/frontend/src/components/funnel/journeys/JourneyNotice.svelte b/frontend/src/components/funnel/journeys/JourneyNotice.svelte new file mode 100644 index 00000000..72d87806 --- /dev/null +++ b/frontend/src/components/funnel/journeys/JourneyNotice.svelte @@ -0,0 +1,80 @@ + + +
+ +

{message}

+
+ + diff --git a/frontend/src/components/funnel/journeys/JourneyStepRow.svelte b/frontend/src/components/funnel/journeys/JourneyStepRow.svelte new file mode 100644 index 00000000..ce0a7246 --- /dev/null +++ b/frontend/src/components/funnel/journeys/JourneyStepRow.svelte @@ -0,0 +1,416 @@ + + +
+ {#if loading} +
+
+ + +
+ + {:else} + + +
+

+ {title} + {#if contributionLabel} + {contributionLabel} + {/if} + {#if detail} + {detail} + {/if} +

+
+ +
+ {#if points !== null && points !== undefined} + +{points}{pointsLabel ? ` ${pointsLabel}` : ''} + {/if} + + {#if actionLabel} + {#if actionHref} + + {busy ? 'Working...' : actionLabel} + + + {:else} + + {/if} + {:else} + {statusLabel} + {/if} +
+ {/if} +
+ + diff --git a/frontend/src/components/funnel/journeys/JourneyUnlockCard.svelte b/frontend/src/components/funnel/journeys/JourneyUnlockCard.svelte new file mode 100644 index 00000000..0fde4cdd --- /dev/null +++ b/frontend/src/components/funnel/journeys/JourneyUnlockCard.svelte @@ -0,0 +1,117 @@ + + +
+ Locked + +

{title}

+

{body}

+ {label} +
+ + diff --git a/frontend/src/components/profile/CommunityProgressJourney.svelte b/frontend/src/components/profile/CommunityProgressJourney.svelte deleted file mode 100644 index ecaa9e9f..00000000 --- a/frontend/src/components/profile/CommunityProgressJourney.svelte +++ /dev/null @@ -1,265 +0,0 @@ - - -
- -
-

- Complete your community journey -

-

- {#if completedCount === 2} - All steps completed! - {:else} - {2 - completedCount} step{2 - completedCount !== 1 ? 's' : ''} remaining to complete your community journey. - {/if} -

-
- - -
-
-
- -
- -
-
-
- {#if hasLinkedX} - - - - - {:else} - - - - {/if} -
-
- Link X (Twitter) - Connect your X account to earn 500 points -
-
-
- {#if hasLinkedX} -
- Done -
- {:else if hasXConnection} - - {:else} - - {/if} -
-
- - -
-
-
- {#if hasLinkedDiscord} - - - - - {:else} - - - - {/if} -
-
- Link Discord - Connect your Discord account to earn 500 points -
-
-
- {#if hasLinkedDiscord} -
- Done -
- {:else if hasDiscordConnection} - - {:else} - - {/if} -
-
-
-
- - diff --git a/frontend/src/components/profile/CommunityView.svelte b/frontend/src/components/profile/CommunityView.svelte index 9027d9f0..3d399844 100644 --- a/frontend/src/components/profile/CommunityView.svelte +++ b/frontend/src/components/profile/CommunityView.svelte @@ -2,7 +2,6 @@ import { push } from "svelte-spa-router"; import ProfileHighlights from "./ProfileHighlights.svelte"; import ProfileRecentContributions from "./ProfileRecentContributions.svelte"; - import CommunityProgressJourney from "./CommunityProgressJourney.svelte"; import ProfilePoaps from "../poaps/ProfilePoaps.svelte"; let { @@ -12,18 +11,8 @@ communityStatsLoading = false, poapCount = 0, poapCountLoading = false, - onSocialLinked = () => {}, - onClaimX = () => {}, - onClaimDiscord = () => {}, - isClaimingX = false, - isClaimingDiscord = false, } = $props(); - let showJourney = $derived( - isOwnProfile && - participant?.creator && - !(participant?.has_community_link_x && participant?.has_community_link_discord) - ); let showCommunityActivity = $derived(Boolean(participant?.creator)); let showPoaps = $derived(poapCount > 0); @@ -64,20 +53,6 @@ {/if}
- - {#if showJourney} -
- -
- {/if} -
{#if showCommunityActivity} diff --git a/frontend/src/components/profile/JourneyActions.svelte b/frontend/src/components/profile/JourneyActions.svelte deleted file mode 100644 index 6511eaa6..00000000 --- a/frontend/src/components/profile/JourneyActions.svelte +++ /dev/null @@ -1,231 +0,0 @@ - - -
-
-

- Your Journeys -

-
- -
- -
-
-
-

- Start as a Builder -

- -
-

- Deploy intelligent contracts and contribute repos to earn builder - points. -

-
- -
- {#if builderState === "completed"} - - {:else if builderState === "ongoing"} - - {:else} - - {/if} -
-
- - -
-
-
-

- Become a Validator -

- -
-

- Operate a node to secure the network and participate in consensus to - earn rewards. -

-
- -
- {#if validatorState === "completed"} - - {:else if validatorState === "ongoing"} - - {:else} - - {/if} -
-
- - -
-
-
-

- Join the community -

- -
-

- Link your social accounts and submit community work to earn community - points. -

-
- -
- {#if communityState === "completed"} - - {:else if communityState === "ongoing"} - - {:else} - - {/if} -
-
-
-
diff --git a/frontend/src/components/profile/ProfileHeader.svelte b/frontend/src/components/profile/ProfileHeader.svelte index fed92740..9ada5362 100644 --- a/frontend/src/components/profile/ProfileHeader.svelte +++ b/frontend/src/components/profile/ProfileHeader.svelte @@ -4,11 +4,12 @@ import Avatar from "../Avatar.svelte"; import CategoryIcon from "../portal/CategoryIcon.svelte"; import SocialLink from "../SocialLink.svelte"; + import { hasStartedJourney } from "../../lib/roleState.js"; let { participant = null, isOwnProfile = false, - isValidatorOnly = false, + communityJourneyInProgress = false, onParticipantUpdated = () => {}, } = $props(); @@ -35,6 +36,12 @@ participant.has_validator_waitlist || participant.has_builder_welcome), ); + + // In-progress (started, not earned) role journeys: the badge is greyed and + // shown only to the profile owner — it is not public until the role is earned. + let builderStarted = $derived(hasStartedJourney(participant, "builder")); + let validatorStarted = $derived(hasStartedJourney(participant, "validator")); + let communityStarted = $derived(hasStartedJourney(participant, "community")); let discordRoles = $derived.by(() => { const roles = participant?.discord_connection?.roles || []; return [...roles].sort((a, b) => (b.position || 0) - (a.position || 0) || String(a.name).localeCompare(String(b.name))); @@ -103,8 +110,7 @@ class="profile-name text-[40px] font-semibold text-black leading-[40px] min-w-0" style="letter-spacing: -0.8px; font-family: 'F37 Lineca', 'Geist', sans-serif;" > - {participant?.name || - (isValidatorOnly ? "Validator" : "Participant")} + {participant?.name || "Participant"}
@@ -127,7 +133,7 @@
{/if} - {#if participant?.creator} + {#if participant?.creator && !communityJourneyInProgress}
@@ -135,24 +141,48 @@ Contributing to the GenLayer community
+ {:else if isOwnProfile && (communityStarted || communityJourneyInProgress)} +
+ +
+ Community + Journey in progress +
+
{/if} - {#if participant?.builder || participant?.has_builder_welcome} + {#if participant?.builder}
Builder - {participant?.builder ? 'Deploying contracts and contributing code' : 'Builder journey in progress'} + Deploying contracts and contributing code +
+
+ {:else if isOwnProfile && builderStarted} +
+ +
+ Builder + Journey in progress
{/if} - {#if participant?.validator || participant?.has_validator_waitlist} + {#if participant?.validator}
Validator - {participant?.validator ? 'Securing the network through consensus' : 'On the validator waitlist'} + Securing the network through consensus +
+
+ {:else if isOwnProfile && validatorStarted} +
+ +
+ Validator + Journey in progress
{/if} diff --git a/frontend/src/components/profile/ProgressJourney.svelte b/frontend/src/components/profile/ProgressJourney.svelte deleted file mode 100644 index 5f8fcf7e..00000000 --- a/frontend/src/components/profile/ProgressJourney.svelte +++ /dev/null @@ -1,955 +0,0 @@ - - -
- -
-

- Complete your builder journey -

-

- {8 - completedCount} steps remaining to complete your builder journey. -

-
- - -
-
-
- -
- -
-
-
- {#if walletAddress} - - - - - {:else} - - - - {/if} -
-
- Connect your wallet - Link your Web3 wallet -
-
-
- {#if walletAddress} -
- Connected -
- {:else} -
- Connected -
- {/if} -
-
- - -
-
-
- {#if hasBuilderWelcome} - - - - - {:else} - - - - {/if} -
-
- Earn your first points - Claim your Builder Welcome Contribution -
-
-
- {#if hasBuilderWelcome} -
- Done -
- {:else} - - {/if} -
-
- - -
-
-
- {#if githubUsername} - - - - - {:else} - - - - {/if} -
-
- Connect your GitHub - Link your GitHub account -
-
-
- {#if githubUsername} -
- Connected -
- {:else} - - {/if} -
-
- - -
-
-
- {#if hasStarredRepo} - - - - - {:else} - - - - {/if} -
-
- Star the Boilerplate repo - Star the boilerplate repo on GitHub -
-
-
- {#if hasStarredRepo} -
- Starred -
- {:else if githubUsername} -
- {#if onCheckRepoStar} - - {/if} - - Star Repo - -
- {:else} -
- Star Repo -
- {/if} -
-
- - -
-
-
- {#if hasAsimovNetwork} - - - - - {:else} - - - - {/if} -
-
- Add GenLayer Testnet Chain - Add the testnet to your wallet -
-
-
- {#if hasAsimovNetwork} -
- Added -
- {:else if walletAddress} -
- - -
- {:else} -
- Add Network -
- {/if} -
-
- - -
-
-
- {#if hasTestnetBalance} - - - - - {:else} - - - - {/if} -
-
- Top-up with Testnet GEN - Get testnet tokens for deployment -
-
-
- {#if hasTestnetBalance} - Get More - {:else} -
- {#if onRefreshBalance && hasAsimovNetwork} - - {/if} - Get Tokens -
- {/if} -
-
- - -
-
-
- {#if hasStudioNetwork} - - - - - {:else} - - - - {/if} -
-
- Add Studio Network - Connect to studio.genlayer.com -
-
-
- {#if hasStudioNetwork} -
- Added -
- {:else if walletAddress} -
- - -
- {:else} -
- Add Network -
- {/if} -
-
- - -
-
-
- {#if hasDeployedContract} - - - - - {:else} - - - - {/if} -
-
- Deploy your first contract - Deploy an intelligent contract -
-
-
- {#if hasDeployedContract} -
- Deployed -
- {:else if onOpenStudio} - - {:else} - - Open Studio - - {/if} -
-
-
- - -
- -
-
diff --git a/frontend/src/components/profile/RankingsWidget.svelte b/frontend/src/components/profile/RankingsWidget.svelte index b9230762..baf1e3a7 100644 --- a/frontend/src/components/profile/RankingsWidget.svelte +++ b/frontend/src/components/profile/RankingsWidget.svelte @@ -24,9 +24,6 @@ let isValidator = $derived(!!participant?.validator); let hasValidatorPoints = $derived((validatorStats?.totalPoints ?? 0) > 0); let isCreator = $derived(!!participant?.creator); - let isValidatorWaitlist = $derived( - !!participant?.has_validator_waitlist && !participant?.validator, - ); function getTabForRoleKey(roleKey: string) { if (roleKey === "builder") return "Builders"; @@ -67,7 +64,7 @@ }); let hasAnyRankableRole = $derived( - isBuilder || isCreator || isValidatorWaitlist || hasValidatorPoints, + isBuilder || isCreator || (isValidator && hasValidatorPoints), ); let activeTab: string | null = $state(null); @@ -460,12 +457,6 @@ activeList.filter((row: any) => !row.isEllipsis).slice(0, 4), ); - function scrollToJourneys() { - const el = document.querySelector(".journey-actions-section"); - if (el) { - el.scrollIntoView({ behavior: "smooth", block: "start" }); - } - } {#if hasAnyRankableRole} @@ -786,32 +777,6 @@
{/if} - {:else if isOwnProfile} - {/if} @@ -861,52 +826,6 @@ {/if} - {:else if isValidatorWaitlist} -
-
- -
- Validator Waitlist - Awaiting graduation to validator -
-
-
- {:else if isOwnProfile} - {/if} @@ -959,32 +878,6 @@ {/if} - {:else if isOwnProfile} - {/if} diff --git a/frontend/src/components/profile/RoleJourneyCard.svelte b/frontend/src/components/profile/RoleJourneyCard.svelte new file mode 100644 index 00000000..b27c867f --- /dev/null +++ b/frontend/src/components/profile/RoleJourneyCard.svelte @@ -0,0 +1,256 @@ + + +
+ + +
+ +
+

{meta.journeyLabel}

+

{title}

+
+ {statusLabel} +
+ +

{description}

+ + + {ctaText} + + +
+ + diff --git a/frontend/src/components/social-tasks/SocialTaskCard.svelte b/frontend/src/components/social-tasks/SocialTaskCard.svelte index ef04087f..e6882062 100644 --- a/frontend/src/components/social-tasks/SocialTaskCard.svelte +++ b/frontend/src/components/social-tasks/SocialTaskCard.svelte @@ -8,7 +8,7 @@ import { getCategoryPillColors } from '../../lib/categoryColors.js'; import SocialLink from '../SocialLink.svelte'; - let { task, onCompleted = () => {} } = $props(); + let { task, onCompleted = () => {}, pointsLabel = 'pts' } = $props(); // ~5 seconds after a click-through user opens the link, we credit them. const CLICK_THROUGH_DELAY_MS = 5000 + Math.floor(Math.random() * 500); @@ -83,9 +83,9 @@ const data = res.data; const points = data?.points_awarded ?? task.points; if (data?.status === 'already_completed') { - showSuccess(`Already completed (${points} pts).`); + showSuccess(`Already completed (${points} ${pointsLabel}).`); } else { - showSuccess(`Nice. ${points} points awarded.`); + showSuccess(`Nice. ${points} ${pointsLabel} awarded.`); } onCompleted({ task, completion: data }); } catch (err) { @@ -141,7 +141,7 @@ style="background: {colors.pillBg}; color: {colors.pillText};" > - +{isCompleted ? (task.points_awarded ?? task.points) : task.points} pts + +{isCompleted ? (task.points_awarded ?? task.points) : task.points} {pointsLabel} diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 075065a4..af182074 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -206,18 +206,16 @@ export const journeyAPI = { startValidatorJourney: () => api.post('/users/start_validator_journey/'), startBuilderJourney: () => api.post('/users/start_builder_journey/'), completeBuilderJourney: () => api.post('/users/complete_builder_journey/'), + deploymentStatus: () => api.get('/users/deployment_status/'), linkXAccount: () => api.post('/users/link_x_account/'), - linkDiscordAccount: () => api.post('/users/link_discord_account/') -}; - -// Community API (backend uses 'creator' terminology) -export const creatorAPI = { - joinAsCreator: () => api.post('/creators/join/') -}; - -// GitHub OAuth API -export const githubAPI = { - checkStar: () => api.get('/users/github/check-star/') + linkDiscordAccount: () => api.post('/users/link_discord_account/'), + linkGithubAccount: () => api.post('/users/link_github_account/'), + // Point-free "started" marker for any role (builder|validator|community). + startRoleJourney: (role) => api.post('/users/start_role_journey/', { role }), + // Community journey (5 steps -> Creator role) + communityJourney: () => api.get('/users/community_journey/'), + verifyCommunityPost: (postUrl) => api.post('/users/verify_community_post/', { post_url: postUrl }), + completeCommunityJourney: () => api.post('/users/complete_community_journey/') }; // Social connections API diff --git a/frontend/src/lib/auth.js b/frontend/src/lib/auth.js index 642b3be0..412f4068 100644 --- a/frontend/src/lib/auth.js +++ b/frontend/src/lib/auth.js @@ -4,6 +4,8 @@ import { writable } from 'svelte/store'; import { userStore } from './userStore'; import { API_BASE_URL } from './config.js'; import { attachCsrfToken } from './csrf.js'; +import { detectCategoryFromRoute } from '../stores/category.js'; +import { roleForCategory } from './roleState.js'; // Create a Svelte store for authentication state const createAuthStore = () => { @@ -275,6 +277,17 @@ export async function signInWithEthereum(provider = null, walletName = 'wallet', authState.setLoading(true); authState.setError(null); + // Capture the role implied by the page the user started auth from (e.g. the + // builder landing), BEFORE the post-login redirect moves them. The + // profile-completion guard uses it to preselect the role for first-time users, + // regardless of where connect was triggered (sidebar / navbar / landing). + try { + sessionStorage.setItem( + 'onboardingRole', + roleForCategory(detectCategoryFromRoute(window.location.pathname)), + ); + } catch {} + try { // Connect to wallet and get address const address = await connectWallet(provider, walletName); diff --git a/frontend/src/lib/roleState.js b/frontend/src/lib/roleState.js new file mode 100644 index 00000000..31f5ba2b --- /dev/null +++ b/frontend/src/lib/roleState.js @@ -0,0 +1,71 @@ +// Role funnel state, derived from the /users/me/ payload (userStore.user). +// +// Journeys grant the ROLE (a profile row), never points — so "earned" is the +// presence of the role's profile object on the user, and "started" is a cheap +// best-effort signal that the user took a first journey step. No parallel +// backend state is kept; everything here reads fields already on /users/me/. + +// The builder journey's single points-bearing step. Kept in sync with the +// backend seed (social_tasks migration 0004 / settings.BUILDER_JOURNEY_TASK_SLUG). +export const STAR_BOILERPLATE_TASK_SLUG = 'star-genlayer-boilerplate'; + +const ROUTE_BASE = { + builder: '/builders', + validator: '/validators', + community: '/community', +}; + +export function rolePath(category) { + return ROUTE_BASE[category] || '/'; +} + +export function journeyPath(category) { + return ROUTE_BASE[category] ? `${ROUTE_BASE[category]}/journey` : '/'; +} + +export function hasEarnedRole(user, category) { + if (!user) return false; + if (category === 'builder') return !!user.builder; + if (category === 'validator') return !!user.validator; + if (category === 'community') return !!user.creator; + return false; +} + +// "Started but not earned", from durable point-free `-welcome` markers set +// when the user clicks "Start the journey" (plus the role's deeper signals for +// back-compat: validator waitlist, community social links). +export function hasStartedJourney(user, category) { + if (!user || hasEarnedRole(user, category)) return false; + if (category === 'builder') return !!user.has_builder_welcome; + if (category === 'validator') return !!(user.has_validator_welcome || user.has_validator_waitlist); + if (category === 'community') { + return !!(user.has_community_welcome || user.has_community_link_x || user.has_community_link_discord); + } + return false; +} + +// 'unauthenticated' | 'none' | 'started' | 'earned' +export function roleFunnelState(isAuthenticated, user, category) { + if (!isAuthenticated) return 'unauthenticated'; + if (hasEarnedRole(user, category)) return 'earned'; + if (hasStartedJourney(user, category)) return 'started'; + return 'none'; +} + +// Map an entry route's category (incl. 'global'/'steward') to a funnel role, +// for onboarding preselect. Anything non-role defaults to community. +export function roleForCategory(category) { + if (category === 'builder' || category === 'validator') return category; + return 'community'; +} + +// "Engaged member" — past a fresh signup: earned a role, started a journey, +// linked a community social, is a steward, or in a working group. Used to gate +// first-run UI (What's New) away from brand-new accounts still in onboarding. +export function hasAnyRoleOrJourney(user) { + if (!user) return false; + if (user.steward || (user.working_groups && user.working_groups.length)) return true; + return ['builder', 'validator', 'community'].some( + (c) => hasEarnedRole(user, c) || hasStartedJourney(user, c), + ); +} diff --git a/frontend/src/routes/BuilderJourney.svelte b/frontend/src/routes/BuilderJourney.svelte new file mode 100644 index 00000000..ba0afa31 --- /dev/null +++ b/frontend/src/routes/BuilderJourney.svelte @@ -0,0 +1,1153 @@ + + + + Builder Journey | GenLayer Portal + + +
+ + + + +
+ {#if loading} + {#each Array(TOTAL_STEPS) as _, i} + + {/each} + {:else} +
+ +
+ +
+ + + {#if isActive('github') && !githubConnection} +
+
+

Link the GitHub account you will use to star the boilerplate repository. This step awards 25 BP once the link is verified.

+
+ +
+ {/if} +
+ +
+ + + {#if isActive('star') && starTask && !starred} +
+
+

Open the repository, star it with your linked GitHub account, then verify the task here.

+
+
+ +
+
+ {:else if isActive('star') && loadError} +
+

{loadError}

+ +
+ {/if} +
+ +
+ + + {#if isActive('networks')} +
+
+

Add Bradbury, Asimov, and Studio to this wallet. The journey continues when all three network entries have been confirmed.

+
+
+ {#each networkItems as network} +
+ +
+ {network.label} + {network.detail} +
+ {#if network.done} + Added + {:else} + + {/if} +
+ {/each} + +
+ + +
+
+
+ {/if} +
+ +
+ + + {#if isActive('topup')} +
+
+

Use the faucet, then check the wallet balance to continue. The journey advances once Testnet GEN is detected.

+
+
+ +
+
+ {/if} +
+ +
+ + + {#if isActive('deploy')} +
+
+

Deploy from Studio with this wallet, then verify the deployment. Once verified, the Claim Builder Role button above becomes available.

+
+
+ +
+
+ {/if} +
+ + {#if requiredStepsComplete} +
+
+

Ready to claim the Builder role

+ The remaining steps are recommended, not required. +
+
+ {/if} + {/if} +
+ +
+ +
+ {#each unlocks as item} + + {/each} +
+
+
+ + diff --git a/frontend/src/routes/CommunityJourney.svelte b/frontend/src/routes/CommunityJourney.svelte new file mode 100644 index 00000000..1321435e --- /dev/null +++ b/frontend/src/routes/CommunityJourney.svelte @@ -0,0 +1,795 @@ + + + + Community Journey | GenLayer Portal + + +
+ + + + +
+ {#if loading} + {#each Array(TOTAL_STEPS) as _, i} + + {/each} + {:else} +
+ claimLinkedAccount('x')} + /> + + {#if isActive('link_x') && !stepDone('link_x')} + + {/if} +
+ +
+ claimLinkedAccount('discord')} + /> + + {#if isActive('link_discord') && !stepDone('link_discord')} + + {/if} +
+ +
+ + + {#if isActive('follow_x') && !stepDone('follow_x')} +
+
+

Follow @genlayer with your linked X account, then verify the task.

+
+
+ {#if followTask} + + {:else} + + {/if} +
+
+ {/if} +
+ +
+ + + {#if isActive('join_discord') && !stepDone('join_discord')} +
+
+

Join the GenLayer Discord with your linked Discord account, then verify the task.

+
+
+ {#if discordTask} + + {:else} + + {/if} +
+
+ {/if} +
+ +
+ + + {#if isActive('x_post') && !stepDone('x_post')} +
+
+

Copy the generated post, publish it from your linked X account, then paste the public post URL to verify it.

+ +
+ + Post on X + +
+
+ +
+ + + +
+
+ {/if} +
+ + {#if complete} +
+
+

Community journey complete

+ Click to finish. +
+
+ {/if} + {/if} +
+ +
+ +
+ {#each unlocks as item} + + {/each} +
+
+
+ + diff --git a/frontend/src/routes/Profile.svelte b/frontend/src/routes/Profile.svelte index 95710d00..28028bb4 100644 --- a/frontend/src/routes/Profile.svelte +++ b/frontend/src/routes/Profile.svelte @@ -6,23 +6,20 @@ statsAPI, leaderboardAPI, journeyAPI, - creatorAPI, - getCurrentUser, - githubAPI, validatorsAPI, poapsAPI, } from "../lib/api"; import { authState } from "../lib/auth"; import { getValidatorBalance } from "../lib/blockchain"; - import { showSuccess, showWarning, showError } from "../lib/toastStore"; + import { showSuccess, showWarning } from "../lib/toastStore"; import { parseMarkdown } from "../lib/markdownLoader.js"; import { getTopRole } from "../lib/profileRole.js"; + import { hasStartedJourney } from "../lib/roleState.js"; import ProfileHeader from "../components/profile/ProfileHeader.svelte"; import RankingsWidget from "../components/profile/RankingsWidget.svelte"; - import JourneyActions from "../components/profile/JourneyActions.svelte"; - import ProgressJourney from "../components/profile/ProgressJourney.svelte"; import RoleView from "../components/profile/RoleView.svelte"; + import RoleJourneyCard from "../components/profile/RoleJourneyCard.svelte"; import StewardView from "../components/profile/StewardView.svelte"; import CommunityView from "../components/profile/CommunityView.svelte"; import ReferralsView from "../components/profile/ReferralsView.svelte"; @@ -66,27 +63,22 @@ let balance = $state(null); let testnetBalance = $state(null); let loadingBalance = $state(false); - let hasDeployedContract = $state(false); let isRefreshingBalance = $state(false); - let isClaimingBuilderBadge = $state(false); - let hasStarredRepo = $state(false); - let repoToStar = $state("genlayerlabs/genlayer-project-boilerplate"); - let isCheckingRepoStar = $state(false); - let isCompletingJourney = $state(false); let referralData = $state(null); let loadingReferrals = $state(false); let hasShownStatsErrorToast = $state(false); let validatorWallets = $state([]); let loadingValidatorWallets = $state(false); let referralPoints = $state({ builder_points: 0, validator_points: 0 }); - let isClaimingX = $state(false); - let isClaimingDiscord = $state(false); let contributionStatsLoaded = $state(false); let builderStatsLoaded = $state(false); let validatorStatsLoaded = $state(false); let communityStatsLoaded = $state(false); let poapCount = $state(0); let poapCountLoaded = $state(false); + let communityJourney: any = $state(null); + let communityJourneyCheckKey = $state(""); + let communityJourneyCheckFailed = $state(false); // Check if this is the current user's profile let isOwnProfile = $derived( @@ -120,8 +112,47 @@ !participant.has_builder_welcome, ); + // In-progress role journeys are shown only on the owner's own profile. + let builderInProgress = $derived(isOwnProfile && hasStartedJourney(participant, "builder")); + let validatorWaitlistPending = $derived( + isOwnProfile && participant?.has_validator_waitlist && !participant?.validator, + ); + let validatorInProgress = $derived( + isOwnProfile && (hasStartedJourney(participant, "validator") || validatorWaitlistPending), + ); + let communityJourneyComplete = $derived( + Boolean(communityJourney?.complete && communityJourney?.is_member), + ); + // Only a LOADED journey response that says "started but not yet complete" + // counts as in-progress here. Holding the Creator role alone is NOT enough: + // pre-existing creators from the old backfill never went through the new + // journey, so they have no started-but-incomplete state and must not be + // forced into the grey "in progress" view. + let communityJourneyStartedIncomplete = $derived( + Boolean(communityJourney) && + communityJourney.started === true && + !communityJourneyComplete, + ); + let communityJourneyHasLocalSignal = $derived( + Boolean( + participant?.has_community_welcome || + participant?.has_community_link_x || + participant?.has_community_link_discord, + ), + ); + let communityInProgress = $derived( + isOwnProfile && + (hasStartedJourney(participant, "community") || + communityJourneyHasLocalSignal || + communityJourneyStartedIncomplete), + ); + let profileRoleParticipant = $derived.by(() => { + if (!participant || !communityInProgress || !participant.creator) return participant; + return { ...participant, creator: false }; + }); + let topRole = $derived( - getTopRole(participant, { + getTopRole(profileRoleParticipant, { builderStats, validatorStats, communityStats, @@ -140,7 +171,7 @@ let showReferralsSection = $derived(isOwnProfile || hasReferralData); let showCommunitySection = $derived( - Boolean(participant && (participant.creator || poapCount > 0)), + Boolean(participant && !communityInProgress && (participant.creator || poapCount > 0)), ); let hasValidatorPoints = $derived((validatorStats?.totalPoints ?? 0) > 0); @@ -177,6 +208,58 @@ } }); + $effect(() => { + const key = getCommunityJourneyStatusKey(); + if (key === communityJourneyCheckKey) return; + + communityJourneyCheckKey = key; + communityJourney = null; + communityJourneyCheckFailed = false; + + if (key) { + loadCommunityJourneyStatus(key); + } + }); + + function getCommunityJourneyStatusKey() { + const participantAddress = participant?.address ? String(participant.address).toLowerCase() : ""; + const authAddress = $authState.address ? String($authState.address).toLowerCase() : ""; + const ownProfile = + $authState.isAuthenticated && + participantAddress && + authAddress === participantAddress; + if (!ownProfile) return ""; + + const hasCommunitySignal = + participant?.creator || + participant?.has_community_welcome || + participant?.has_community_link_x || + participant?.has_community_link_discord; + if (!hasCommunitySignal) return ""; + + return [ + participantAddress, + participant.creator ? 1 : 0, + participant.has_community_welcome ? 1 : 0, + participant.has_community_link_x ? 1 : 0, + participant.has_community_link_discord ? 1 : 0, + ].join(":"); + } + + async function loadCommunityJourneyStatus(key = communityJourneyCheckKey) { + if (!key) return; + communityJourneyCheckFailed = false; + try { + const response = await journeyAPI.communityJourney(); + if (key !== communityJourneyCheckKey) return; + communityJourney = response.data || null; + } catch (_) { + if (key === communityJourneyCheckKey) { + communityJourneyCheckFailed = true; + } + } + } + async function refreshBalance() { if (!participant?.address) return; isRefreshingBalance = true; @@ -205,70 +288,6 @@ } } - async function claimBuilderWelcome() { - if (!$authState.isAuthenticated) { - document.querySelector(".auth-button")?.click(); - return; - } - isClaimingBuilderBadge = true; - try { - await journeyAPI.startBuilderJourney(); - participant = await getCurrentUser(); - // Wait for the DOM to render the builder journey section, then scroll to it - requestAnimationFrame(() => { - setTimeout(() => { - const el = document.getElementById("builder-journey-section"); - if (el) { - el.scrollIntoView({ behavior: "smooth", block: "start" }); - } - }, 50); - }); - } catch (err) { - } finally { - isClaimingBuilderBadge = false; - } - } - - async function startCreatorJourney() { - if (!$authState.isAuthenticated) return; - error = null; - try { - const response = await creatorAPI.joinAsCreator(); - if (response.status === 201 || response.status === 200) { - showSuccess( - "You are now a Community Member! Start contributing to the community.", - ); - participant = await getCurrentUser(); - } - } catch (err) { - error = err.response?.data?.message || "Failed to join the community"; - } - } - - async function handleGitHubLinked(updatedUser) { - participant = updatedUser; - } - - async function checkRepoStar() { - if (!participant?.github_connection?.platform_username) return; - isCheckingRepoStar = true; - try { - const response = await githubAPI.checkStar(); - hasStarredRepo = response.data.has_starred; - repoToStar = - response.data.repo || "genlayerlabs/genlayer-project-boilerplate"; - } catch (err) { - hasStarredRepo = false; - } finally { - isCheckingRepoStar = false; - } - } - - function openStudio() { - hasDeployedContract = true; - window.open("https://studio.genlayer.com", "_blank", "noopener,noreferrer"); - } - async function refreshBuilderStats() { const address = participant?.address; if (!address) return; @@ -284,42 +303,6 @@ } catch (_) {} } - async function completeBuilderJourney() { - if (!$authState.isAuthenticated || isCompletingJourney) return; - isCompletingJourney = true; - try { - const response = await journeyAPI.completeBuilderJourney(); - if (response.status === 201 || response.status === 200) { - if (response.data?.user) { - participant = response.data.user; - } - showSuccess("Congratulations! 🎉 You are now a GenLayer Builder!"); - refreshBuilderStats(); - } - } catch (err) { - if (err.response?.status === 200) { - if (err.response.data?.user) participant = err.response.data.user; - showSuccess("Congratulations! 🎉 You are now a GenLayer Builder!"); - refreshBuilderStats(); - } else if (err.response?.status === 400) { - showError( - err.response?.data?.error || "Some requirements are not yet met.", - ); - } else { - showError("Something went wrong. Please try again later."); - } - } finally { - isCompletingJourney = false; - } - } - - async function refreshCommunityStats() { - if (!participant?.address) return; - try { - const res = await statsAPI.getUserStats(participant.address, "community"); - if (res.data) communityStats = res.data; - } catch (_) {} - } async function fetchPoapCount(participantAddress: string) { const isCurrentRequest = () => @@ -347,42 +330,13 @@ } } - async function handleClaimX() { - if (!$authState.isAuthenticated || isClaimingX) return; - isClaimingX = true; - try { - await journeyAPI.linkXAccount(); - participant = await getCurrentUser(); - refreshCommunityStats(); - } catch (err) { - showError(err.response?.data?.error || 'Failed to claim X linking reward'); - } finally { - isClaimingX = false; - } - } - - async function handleClaimDiscord() { - if (!$authState.isAuthenticated || isClaimingDiscord) return; - isClaimingDiscord = true; - try { - await journeyAPI.linkDiscordAccount(); - participant = await getCurrentUser(); - refreshCommunityStats(); - } catch (err) { - showError(err.response?.data?.error || 'Failed to claim Discord linking reward'); - } finally { - isClaimingDiscord = false; - } - } - - async function handleCommunityLinked(updatedUser) { - participant = updatedUser; - } - async function fetchParticipantData(participantAddress) { try { loading = true; error = null; + communityJourney = null; + communityJourneyCheckKey = ""; + communityJourneyCheckFailed = false; contributionStatsLoaded = false; builderStatsLoaded = false; validatorStatsLoaded = false; @@ -688,7 +642,12 @@ {error} {:else if participant} - { participant = { ...participant, ...updatedUser }; }} /> + { participant = { ...participant, ...updatedUser }; }} + /> {#if !isValidatorOnly}
@@ -879,60 +838,9 @@ loading={!builderStatsLoaded} />
- {:else if participant?.has_builder_welcome && isOwnProfile} -
-
- -
-
- - -
-

- Builder Journey -

-
- - -
- -
-
+ {:else if builderInProgress} +
+
{/if} @@ -954,6 +862,10 @@ loading={!validatorStatsLoaded} />
+ {:else if validatorInProgress} +
+ +
{/if} @@ -966,13 +878,12 @@ communityStatsLoading={!communityStatsLoaded} {poapCount} poapCountLoading={!poapCountLoaded} - onSocialLinked={handleCommunityLinked} - onClaimX={handleClaimX} - onClaimDiscord={handleClaimDiscord} - {isClaimingX} - {isClaimingDiscord} /> + {:else if communityInProgress} +
+ +
{/if} @@ -988,18 +899,6 @@ {/if} - - {#if isOwnProfile} -
- -
- {/if}
diff --git a/frontend/src/routes/ValidatorWaitlist.svelte b/frontend/src/routes/ValidatorWaitlist.svelte index faf9cd0a..115f5d97 100644 --- a/frontend/src/routes/ValidatorWaitlist.svelte +++ b/frontend/src/routes/ValidatorWaitlist.svelte @@ -1,220 +1,572 @@ -
- -
-

Validator Waitlist

-

- Join the waitlist to become a validator on GenLayer Testnets -

-
- - -
-
- -
- -
-
- 1 -
-
- - - -
-
-

Join Waitlist

-

- Complete the form and join -

-
-
- - -
-
- 2 -
-
- - - -
-
-

Contribute

-

Earn points to climb rankings

-
-
- - -
-
- 3 -
-
- - - -
-
-

Get Selected

-

Top contributors join Testnet

-
+ + Validator Waitlist | GenLayer Portal + + +
+ + + + +
+ {#if loading} + + + {:else} +
+ + +
+
- -
- -
- - - - -
-

Selection Process

-

- Validator selection is based on leaderboard rankings, prioritizing professionals with proven track records. - Validators who bring unique value to the GenLayer ecosystem are also considered. -

+ + {/if} +
+ +
+ + +
+ {#each graduatedUnlocks as item} +
+ Locked + -
-
+

{item.title}

+

{item.body}

+ {item.label} + + {/each}
-
- - -
-

Start Your Validator Journey

- -

- First, complete the application form to provide your technical background and validator experience. - Then join the waitlist to start contributing. -

- -
-
- - - - - Complete Application Form - + +
+ - -
-
- - -
- - - - {#if error} -
{error}
- {/if} + +
+

While you wait

+

Want to know more about being a validator?

+

+ Reach out to @validator-lead on Discord to learn what we look for, + ask about timing, or make your case for an earlier spot. +

-
-
-
\ No newline at end of file + + + Contact the team + + + +
+ + diff --git a/frontend/src/tests/roleState.test.js b/frontend/src/tests/roleState.test.js new file mode 100644 index 00000000..887bd2bd --- /dev/null +++ b/frontend/src/tests/roleState.test.js @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { + roleFunnelState, + rolePath, + journeyPath, + roleForCategory, + hasAnyRoleOrJourney, +} from '../lib/roleState.js'; + +describe('roleState.roleFunnelState', () => { + it('is unauthenticated regardless of user', () => { + expect(roleFunnelState(false, null, 'builder')).toBe('unauthenticated'); + expect(roleFunnelState(false, { builder: {} }, 'builder')).toBe('unauthenticated'); + }); + + it('is earned when the role profile is present', () => { + expect(roleFunnelState(true, { builder: {} }, 'builder')).toBe('earned'); + expect(roleFunnelState(true, { validator: {} }, 'validator')).toBe('earned'); + expect(roleFunnelState(true, { creator: {} }, 'community')).toBe('earned'); + }); + + it('is started when the journey marker is set but role not earned', () => { + expect(roleFunnelState(true, { has_builder_welcome: true }, 'builder')).toBe('started'); + expect(roleFunnelState(true, { has_validator_welcome: true }, 'validator')).toBe('started'); + expect(roleFunnelState(true, { has_validator_waitlist: true }, 'validator')).toBe('started'); + expect(roleFunnelState(true, { has_community_welcome: true }, 'community')).toBe('started'); + expect(roleFunnelState(true, { has_community_link_discord: true }, 'community')).toBe('started'); + }); + + it('does not treat a GitHub link alone as a started builder journey', () => { + expect(roleFunnelState(true, { github_connection: {} }, 'builder')).toBe('none'); + }); + + it('is none when authenticated with no role and no progress', () => { + expect(roleFunnelState(true, {}, 'builder')).toBe('none'); + expect(roleFunnelState(true, { name: 'x' }, 'community')).toBe('none'); + }); + + it('treats earned as higher priority than a started signal', () => { + expect(roleFunnelState(true, { builder: {}, github_connection: {} }, 'builder')).toBe('earned'); + }); +}); + +describe('roleState path helpers', () => { + it('maps roles to their main and journey routes', () => { + expect(rolePath('builder')).toBe('/builders'); + expect(rolePath('validator')).toBe('/validators'); + expect(rolePath('community')).toBe('/community'); + expect(journeyPath('builder')).toBe('/builders/journey'); + expect(journeyPath('community')).toBe('/community/journey'); + }); + + it('maps non-role categories to community for onboarding preselect', () => { + expect(roleForCategory('builder')).toBe('builder'); + expect(roleForCategory('validator')).toBe('validator'); + expect(roleForCategory('community')).toBe('community'); + expect(roleForCategory('global')).toBe('community'); + expect(roleForCategory('steward')).toBe('community'); + }); +}); + +describe('hasAnyRoleOrJourney (gates first-run UI away from new accounts)', () => { + it('is false for a brand-new / not-yet-engaged account', () => { + expect(hasAnyRoleOrJourney(null)).toBe(false); + expect(hasAnyRoleOrJourney({})).toBe(false); + expect(hasAnyRoleOrJourney({ name: 'x', email: 'a@b.com' })).toBe(false); + expect(hasAnyRoleOrJourney({ github_connection: {} })).toBe(false); + }); + + it('is true once a role is earned or a journey started', () => { + expect(hasAnyRoleOrJourney({ builder: {} })).toBe(true); + expect(hasAnyRoleOrJourney({ has_builder_welcome: true })).toBe(true); + expect(hasAnyRoleOrJourney({ has_validator_waitlist: true })).toBe(true); + expect(hasAnyRoleOrJourney({ has_validator_welcome: true })).toBe(true); + expect(hasAnyRoleOrJourney({ has_community_welcome: true })).toBe(true); + expect(hasAnyRoleOrJourney({ creator: {} })).toBe(true); + expect(hasAnyRoleOrJourney({ has_community_link_discord: true })).toBe(true); + expect(hasAnyRoleOrJourney({ steward: {} })).toBe(true); + expect(hasAnyRoleOrJourney({ working_groups: [{ id: 1 }] })).toBe(true); + }); +});