diff --git a/.claude/skills/implement-design.md b/.claude/skills/implement-design.md new file mode 100644 index 00000000..1170eaea --- /dev/null +++ b/.claude/skills/implement-design.md @@ -0,0 +1,252 @@ +--- +name: implement-design +description: Translates Figma designs into production-ready code with 1:1 visual fidelity. Use when implementing UI from Figma files, when user mentions "implement design", "generate code", "implement component", "build Figma design", provides Figma URLs, or asks to build components matching Figma specs. Requires Figma MCP server connection. +metadata: + mcp-server: figma, figma-desktop +--- + +# Implement Design + +## Overview + +This skill provides a structured workflow for translating Figma designs into production-ready code with pixel-perfect accuracy. It ensures consistent integration with the Figma MCP server, proper use of design tokens, and 1:1 visual parity with designs. + +## Prerequisites + +- Figma MCP server must be connected and accessible +- User must provide a Figma URL in the format: `https://figma.com/design/:fileKey/:fileName?node-id=1-2` + - `:fileKey` is the file key + - `1-2` is the node ID (the specific component or frame to implement) +- **OR** when using `figma-desktop` MCP: User can select a node directly in the Figma desktop app (no URL required) +- Project should have an established design system or component library (preferred) + +## Required Workflow + +**Follow these steps in order. Do not skip steps.** + +### Step 1: Get Node ID + +#### Option A: Parse from Figma URL + +When the user provides a Figma URL, extract the file key and node ID to pass as arguments to MCP tools. + +**URL format:** `https://figma.com/design/:fileKey/:fileName?node-id=1-2` + +**Extract:** + +- **File key:** `:fileKey` (the segment after `/design/`) +- **Node ID:** `1-2` (the value of the `node-id` query parameter) + +**Note:** When using the local desktop MCP (`figma-desktop`), `fileKey` is not passed as a parameter to tool calls. The server automatically uses the currently open file, so only `nodeId` is needed. + +**Example:** + +- URL: `https://figma.com/design/kL9xQn2VwM8pYrTb4ZcHjF/DesignSystem?node-id=42-15` +- File key: `kL9xQn2VwM8pYrTb4ZcHjF` +- Node ID: `42-15` + +#### Option B: Use Current Selection from Figma Desktop App (figma-desktop MCP only) + +When using the `figma-desktop` MCP and the user has NOT provided a URL, the tools automatically use the currently selected node from the open Figma file in the desktop app. + +**Note:** Selection-based prompting only works with the `figma-desktop` MCP server. The remote server requires a link to a frame or layer to extract context. The user must have the Figma desktop app open with a node selected. + +### Step 2: Fetch Design Context + +Run `get_design_context` with the extracted file key and node ID. + +``` +get_design_context(fileKey=":fileKey", nodeId="1-2") +``` + +This provides the structured data including: + +- Layout properties (Auto Layout, constraints, sizing) +- Typography specifications +- Color values and design tokens +- Component structure and variants +- Spacing and padding values + +**If the response is too large or truncated:** + +1. Run `get_metadata(fileKey=":fileKey", nodeId="1-2")` to get the high-level node map +2. Identify the specific child nodes needed from the metadata +3. Fetch individual child nodes with `get_design_context(fileKey=":fileKey", nodeId=":childNodeId")` + +### Step 3: Capture Visual Reference + +Run `get_screenshot` with the same file key and node ID for a visual reference. + +``` +get_screenshot(fileKey=":fileKey", nodeId="1-2") +``` + +This screenshot serves as the source of truth for visual validation. Keep it accessible throughout implementation. + +### Step 4: Download Required Assets + +Download any assets (images, icons, SVGs) returned by the Figma MCP server. + +**IMPORTANT:** Follow these asset rules: + +- If the Figma MCP server returns a `localhost` source for an image or SVG, use that source directly +- DO NOT import or add new icon packages - all assets should come from the Figma payload +- DO NOT use or create placeholders if a `localhost` source is provided +- Assets are served through the Figma MCP server's built-in assets endpoint + +### Step 5: Translate to Project Conventions + +Translate the Figma output into this project's framework, styles, and conventions. + +**Key principles:** + +- Treat the Figma MCP output (typically React + Tailwind) as a representation of design and behavior, not as final code style +- Replace Tailwind utility classes with the project's preferred utilities or design system tokens +- Reuse existing components (buttons, inputs, typography, icon wrappers) instead of duplicating functionality +- Use the project's color system, typography scale, and spacing tokens consistently +- Respect existing routing, state management, and data-fetch patterns + +### Step 6: Achieve 1:1 Visual Parity + +Strive for pixel-perfect visual parity with the Figma design. + +**Guidelines:** + +- Prioritize Figma fidelity to match designs exactly +- Avoid hardcoded values - use design tokens from Figma where available +- When conflicts arise between design system tokens and Figma specs, prefer design system tokens but adjust spacing or sizes minimally to match visuals +- Follow WCAG requirements for accessibility +- Add component documentation as needed + +### Step 7: Validate Against Figma + +Before marking complete, validate the final UI against the Figma screenshot. + +**Validation checklist:** + +- [ ] Layout matches (spacing, alignment, sizing) +- [ ] Typography matches (font, size, weight, line height) +- [ ] Colors match exactly +- [ ] Interactive states work as designed (hover, active, disabled) +- [ ] Responsive behavior follows Figma constraints +- [ ] Assets render correctly +- [ ] Accessibility standards met + +## Implementation Rules + +### Component Organization + +- Place UI components in the project's designated design system directory +- Follow the project's component naming conventions +- Avoid inline styles unless truly necessary for dynamic values + +### Design System Integration + +- ALWAYS use components from the project's design system when possible +- Map Figma design tokens to project design tokens +- When a matching component exists, extend it rather than creating a new one +- Document any new components added to the design system + +### Code Quality + +- Avoid hardcoded values - extract to constants or design tokens +- Keep components composable and reusable +- Add TypeScript types for component props +- Include JSDoc comments for exported components + +## Examples + +### Example 1: Implementing a Button Component + +User says: "Implement this Figma button component: https://figma.com/design/kL9xQn2VwM8pYrTb4ZcHjF/DesignSystem?node-id=42-15" + +**Actions:** + +1. Parse URL to extract fileKey=`kL9xQn2VwM8pYrTb4ZcHjF` and nodeId=`42-15` +2. Run `get_design_context(fileKey="kL9xQn2VwM8pYrTb4ZcHjF", nodeId="42-15")` +3. Run `get_screenshot(fileKey="kL9xQn2VwM8pYrTb4ZcHjF", nodeId="42-15")` for visual reference +4. Download any button icons from the assets endpoint +5. Check if project has existing button component +6. If yes, extend it with new variant; if no, create new component using project conventions +7. Map Figma colors to project design tokens (e.g., `primary-500`, `primary-hover`) +8. Validate against screenshot for padding, border radius, typography + +**Result:** Button component matching Figma design, integrated with project design system. + +### Example 2: Building a Dashboard Layout + +User says: "Build this dashboard: https://figma.com/design/pR8mNv5KqXzGwY2JtCfL4D/Dashboard?node-id=10-5" + +**Actions:** + +1. Parse URL to extract fileKey=`pR8mNv5KqXzGwY2JtCfL4D` and nodeId=`10-5` +2. Run `get_metadata(fileKey="pR8mNv5KqXzGwY2JtCfL4D", nodeId="10-5")` to understand the page structure +3. Identify main sections from metadata (header, sidebar, content area, cards) and their child node IDs +4. Run `get_design_context(fileKey="pR8mNv5KqXzGwY2JtCfL4D", nodeId=":childNodeId")` for each major section +5. Run `get_screenshot(fileKey="pR8mNv5KqXzGwY2JtCfL4D", nodeId="10-5")` for the full page +6. Download all assets (logos, icons, charts) +7. Build layout using project's layout primitives +8. Implement each section using existing components where possible +9. Validate responsive behavior against Figma constraints + +**Result:** Complete dashboard matching Figma design with responsive layout. + +## Best Practices + +### Always Start with Context + +Never implement based on assumptions. Always fetch `get_design_context` and `get_screenshot` first. + +### Incremental Validation + +Validate frequently during implementation, not just at the end. This catches issues early. + +### Document Deviations + +If you must deviate from the Figma design (e.g., for accessibility or technical constraints), document why in code comments. + +### Reuse Over Recreation + +Always check for existing components before creating new ones. Consistency across the codebase is more important than exact Figma replication. + +### Design System First + +When in doubt, prefer the project's design system patterns over literal Figma translation. + +## Common Issues and Solutions + +### Issue: Figma output is truncated + +**Cause:** The design is too complex or has too many nested layers to return in a single response. +**Solution:** Use `get_metadata` to get the node structure, then fetch specific nodes individually with `get_design_context`. + +### Issue: Design doesn't match after implementation + +**Cause:** Visual discrepancies between the implemented code and the original Figma design. +**Solution:** Compare side-by-side with the screenshot from Step 3. Check spacing, colors, and typography values in the design context data. + +### Issue: Assets not loading + +**Cause:** The Figma MCP server's assets endpoint is not accessible or the URLs are being modified. +**Solution:** Verify the Figma MCP server's assets endpoint is accessible. The server serves assets at `localhost` URLs. Use these directly without modification. + +### Issue: Design token values differ from Figma + +**Cause:** The project's design system tokens have different values than those specified in the Figma design. +**Solution:** When project tokens differ from Figma values, prefer project tokens for consistency but adjust spacing/sizing to maintain visual fidelity. + +## Understanding Design Implementation + +The Figma implementation workflow establishes a reliable process for translating designs to code: + +**For designers:** Confidence that implementations will match their designs with pixel-perfect accuracy. +**For developers:** A structured approach that eliminates guesswork and reduces back-and-forth revisions. +**For teams:** Consistent, high-quality implementations that maintain design system integrity. + +By following this workflow, you ensure that every Figma design is implemented with the same level of care and attention to detail. + +## Additional Resources + +- [Figma MCP Server Documentation](https://developers.figma.com/docs/figma-mcp-server/) +- [Figma MCP Server Tools and Prompts](https://developers.figma.com/docs/figma-mcp-server/tools-and-prompts/) +- [Figma Variables and Design Tokens](https://help.figma.com/hc/en-us/articles/15339657135383-Guide-to-variables-in-Figma) diff --git a/CLAUDE.md b/CLAUDE.md index d31a7714..618fcae5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,14 @@ # Points: GenLayer Testnet Program Tracking System +## ALWAYS Use Teammates (Teams), NEVER Single Tasks +**CRITICAL**: When delegating work to agents, ALWAYS use TeamCreate + Task with `team_name` to spawn **teammates**. NEVER use the Task tool as a standalone single agent. Every piece of work must go through a team: +1. Create a team with `TeamCreate` +2. Spawn teammates with `Task` using `team_name` parameter +3. Coordinate via `SendMessage` and `TaskList` +4. Shut down teammates and `TeamDelete` when done + +This applies to ALL work β€” backend, frontend, research, everything. No exceptions. + ## πŸ“š Quick Reference Documentation **Important**: This project has detailed documentation for faster development: - **Backend Documentation**: See `backend/CLAUDE.md` for Django structure, API endpoints, models, and patterns diff --git a/backend/api/urls.py b/backend/api/urls.py index aa95b351..2d5162d1 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -1,7 +1,7 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter from users.views import UserViewSet -from contributions.views import ContributionTypeViewSet, ContributionViewSet, EvidenceViewSet, SubmittedContributionViewSet, StewardSubmissionViewSet, MissionViewSet, StartupRequestViewSet +from contributions.views import ContributionTypeViewSet, ContributionViewSet, EvidenceViewSet, SubmittedContributionViewSet, StewardSubmissionViewSet, MissionViewSet, StartupRequestViewSet, FeaturedContentViewSet, AlertViewSet from leaderboard.views import GlobalLeaderboardMultiplierViewSet, LeaderboardViewSet from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView from .metrics_views import ActiveValidatorsView, ContributionTypesStatsView, ParticipantsGrowthView @@ -18,6 +18,8 @@ router.register(r'steward-submissions', StewardSubmissionViewSet, basename='steward-submission') router.register(r'missions', MissionViewSet, basename='mission') router.register(r'startup-requests', StartupRequestViewSet, basename='startup-request') +router.register(r'featured', FeaturedContentViewSet, basename='featured') +router.register(r'alerts', AlertViewSet, basename='alert') # The API URLs are now determined automatically by the router urlpatterns = [ diff --git a/backend/builders/views.py b/backend/builders/views.py index 5e2a2934..1acbc7a4 100644 --- a/backend/builders/views.py +++ b/backend/builders/views.py @@ -2,10 +2,8 @@ from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated, AllowAny -from django.db.models import Min from .models import Builder from users.serializers import BuilderSerializer -from contributions.models import Contribution class BuilderViewSet(viewsets.ModelViewSet): @@ -72,6 +70,7 @@ def newest_builders(self, request): 'address': builder.user.address, 'name': builder.user.name, 'profile_image_url': builder.user.profile_image_url, + 'builder': True, 'created_at': builder.created_at }) diff --git a/backend/contributions/admin.py b/backend/contributions/admin.py index f41ccba1..846844ee 100644 --- a/backend/contributions/admin.py +++ b/backend/contributions/admin.py @@ -11,7 +11,7 @@ from django.core.exceptions import ValidationError from django.contrib.auth import get_user_model from datetime import datetime -from .models import Category, ContributionType, Contribution, SubmittedContribution, Evidence, ContributionHighlight, Mission, StartupRequest, SubmissionNote +from .models import Category, ContributionType, Contribution, SubmittedContribution, Evidence, ContributionHighlight, Mission, StartupRequest, SubmissionNote, FeaturedContent, Alert from .validator_forms import CreateValidatorForm from leaderboard.models import GlobalLeaderboardMultiplier @@ -674,3 +674,71 @@ def get_status(self, obj): else: return format_html('● Inactive') get_status.short_description = 'Status' + + +@admin.register(FeaturedContent) +class FeaturedContentAdmin(admin.ModelAdmin): + list_display = ('title', 'content_type', 'user', 'is_active', 'order', 'created_at') + list_filter = ('content_type', 'is_active', 'created_at') + search_fields = ('title', 'description', 'user__name', 'user__address') + list_editable = ('order', 'is_active') + raw_id_fields = ('user', 'contribution') + readonly_fields = ('created_at', 'updated_at') + ordering = ('order', '-created_at') + + fieldsets = ( + (None, { + 'fields': ('content_type', 'title', 'subtitle', 'description', 'is_active', 'order') + }), + ('Relations', { + 'fields': ('user', 'contribution') + }), + ('Links & Media', { + 'fields': ('hero_image_url', 'hero_image_public_id', 'user_profile_image_url', 'user_profile_image_public_id', 'url'), + 'description': 'Paste Cloudinary URLs directly. Public IDs are used for image management.' + }), + ('Metadata', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + +@admin.register(Alert) +class AlertAdmin(admin.ModelAdmin): + list_display = ('id', 'alert_type', 'text_preview', 'get_status', 'order', 'start_date', 'end_date', 'created_at') + list_filter = ('alert_type', 'is_active', 'created_at') + search_fields = ('text',) + list_editable = ('order', 'alert_type') + readonly_fields = ('id', 'created_at', 'updated_at') + ordering = ('order', '-created_at') + + fieldsets = ( + ('Content', { + 'fields': ('id', 'alert_type', 'icon', 'text', 'is_active', 'order') + }), + ('Schedule', { + 'fields': ('start_date', 'end_date'), + 'description': 'Optional: Set dates to control when this alert is visible' + }), + ('Metadata', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + def text_preview(self, obj): + return obj.text[:80] + '...' if len(obj.text) > 80 else obj.text + text_preview.short_description = 'Text' + + def get_status(self, obj): + from django.utils import timezone as tz + now = tz.now() + if not obj.is_active: + return format_html('● Inactive') + if obj.start_date and now < obj.start_date: + return format_html('● Scheduled') + if obj.end_date and now > obj.end_date: + return format_html('● Expired') + return format_html('● Active') + get_status.short_description = 'Status' diff --git a/backend/contributions/management/commands/seed_featured_content.py b/backend/contributions/management/commands/seed_featured_content.py new file mode 100644 index 00000000..da123c37 --- /dev/null +++ b/backend/contributions/management/commands/seed_featured_content.py @@ -0,0 +1,179 @@ +import os + +from django.conf import settings +from django.core.management.base import BaseCommand +from django.contrib.auth import get_user_model +from contributions.models import FeaturedContent +from users.cloudinary_service import CloudinaryService + +User = get_user_model() + + +class Command(BaseCommand): + help = 'Seeds FeaturedContent entries for the portal home page (hero banner and featured builds).' + + def _upload_to_cloudinary(self, image_path, featured_obj, upload_type='hero'): + """ + Upload a local image file to Cloudinary and return the result dict. + Returns None if the file does not exist. + + Args: + image_path: Absolute path to the local image file + featured_obj: FeaturedContent instance (used for naming) + upload_type: 'hero' or 'avatar' + """ + if not os.path.exists(image_path): + self.stdout.write(self.style.WARNING(f" Image not found: {image_path}")) + return None + + try: + with open(image_path, 'rb') as f: + if upload_type == 'hero': + result = CloudinaryService.upload_featured_image(f, featured_obj.pk) + else: + result = CloudinaryService.upload_featured_avatar(f, featured_obj.pk) + self.stdout.write(self.style.SUCCESS(f" Uploaded {upload_type}: {result['url'][:80]}...")) + return result + except Exception as e: + self.stdout.write(self.style.ERROR(f" Cloudinary upload failed for {upload_type}: {e}")) + return None + + def handle(self, *args, **options): + media_root = settings.MEDIA_ROOT + + # ---------------------------------------------------------------- + # 1. Ensure users exist (get_or_create with dummy email/address) + # ---------------------------------------------------------------- + users = {} + user_specs = [ + { + 'name': 'cognocracy', + 'email': 'cognocracy@seed.genlayer.com', + 'address': '0x0000000000000000000000000000000000000001', + }, + { + 'name': 'raskovsky', + 'email': 'raskovsky@seed.genlayer.com', + 'address': '0x0000000000000000000000000000000000000002', + }, + { + 'name': 'GenLayer', + 'email': 'genlayer@seed.genlayer.com', + 'address': '0x0000000000000000000000000000000000000003', + }, + ] + + for spec in user_specs: + user, created = User.objects.get_or_create( + email=spec['email'], + defaults={ + 'name': spec['name'], + 'address': spec['address'], + 'username': spec['name'], + }, + ) + users[spec['name']] = user + if created: + self.stdout.write(self.style.SUCCESS(f" Created user: {spec['name']}")) + else: + self.stdout.write(f" User already exists: {spec['name']}") + + # ---------------------------------------------------------------- + # 2. Hero banner + # ---------------------------------------------------------------- + hero_defaults = { + 'description': 'Deploy intelligent contracts, run validators, and earn GenLayer Points on the latest testnet.', + 'subtitle': 'cognocracy', + 'user': users['cognocracy'], + 'hero_image_url': '', + 'url': '', + 'is_active': True, + 'order': 0, + } + + obj, created = FeaturedContent.objects.update_or_create( + content_type='hero', + title='Argue.fun Launch', + defaults=hero_defaults, + ) + + # Upload hero image to Cloudinary + hero_image_path = os.path.join(media_root, 'featured', 'hero-bg.png') + result = self._upload_to_cloudinary(hero_image_path, obj, 'hero') + if result: + obj.hero_image_url = result['url'] + obj.hero_image_public_id = result['public_id'] + obj.save() + + self.stdout.write( + self.style.SUCCESS(f" {'Created' if created else 'Updated'} hero: {obj.title}") + ) + + # ---------------------------------------------------------------- + # 3. Featured builds + # ---------------------------------------------------------------- + builds = [ + { + 'title': 'Argue.fun', + 'user': users['cognocracy'], + 'hero_image_file': os.path.join(media_root, 'featured', 'argue-fun-bg.jpg'), + 'avatar_file': os.path.join(media_root, 'featured', 'avatars', 'cognocracy-avatar.png'), + 'url': '', + 'order': 0, + }, + { + 'title': 'Internet Court', + 'user': users['raskovsky'], + 'hero_image_file': os.path.join(media_root, 'featured', 'internet-court-bg.jpg'), + 'avatar_file': os.path.join(media_root, 'featured', 'avatars', 'raskovsky-avatar.png'), + 'url': '', + 'order': 1, + }, + { + 'title': 'Rally', + 'user': users['GenLayer'], + 'hero_image_file': os.path.join(media_root, 'featured', 'rally-bg.jpg'), + 'avatar_file': os.path.join(media_root, 'featured', 'avatars', 'genlayer-avatar.png'), + 'url': '', + 'order': 2, + }, + ] + + for build in builds: + obj, created = FeaturedContent.objects.update_or_create( + content_type='build', + title=build['title'], + defaults={ + 'user': build['user'], + 'hero_image_url': '', + 'url': build['url'], + 'is_active': True, + 'order': build['order'], + 'description': '', + 'subtitle': '', + }, + ) + + # Upload hero image to Cloudinary + updated = False + result = self._upload_to_cloudinary(build['hero_image_file'], obj, 'hero') + if result: + obj.hero_image_url = result['url'] + obj.hero_image_public_id = result['public_id'] + updated = True + + # Upload avatar to Cloudinary + result = self._upload_to_cloudinary(build['avatar_file'], obj, 'avatar') + if result: + obj.user_profile_image_url = result['url'] + obj.user_profile_image_public_id = result['public_id'] + updated = True + + if updated: + obj.save() + + self.stdout.write( + self.style.SUCCESS(f" {'Created' if created else 'Updated'} build: {obj.title}") + ) + + self.stdout.write(self.style.SUCCESS('\nFeatured content seeded successfully.')) diff --git a/backend/contributions/migrations/0033_alter_submissionnote_options_featuredcontent.py b/backend/contributions/migrations/0033_alter_submissionnote_options_featuredcontent.py new file mode 100644 index 00000000..4e6f8413 --- /dev/null +++ b/backend/contributions/migrations/0033_alter_submissionnote_options_featuredcontent.py @@ -0,0 +1,41 @@ +# Generated by Django 6.0.2 on 2026-02-25 02:08 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contributions', '0032_submittedcontribution_proposal_fields_submissionnote'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterModelOptions( + name='submissionnote', + options={'ordering': ['-created_at']}, + ), + migrations.CreateModel( + name='FeaturedContent', + 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)), + ('content_type', models.CharField(choices=[('hero', 'Hero Banner'), ('build', 'Featured Build'), ('community', 'Featured Community'), ('validator_steward', 'Featured Validator/Steward')], max_length=20)), + ('title', models.CharField(max_length=200)), + ('description', models.TextField(blank=True)), + ('subtitle', models.CharField(blank=True, max_length=200)), + ('hero_image_url', models.URLField(blank=True, max_length=500)), + ('url', models.URLField(blank=True, max_length=500)), + ('is_active', models.BooleanField(default=True)), + ('order', models.PositiveIntegerField(default=0)), + ('contribution', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='featured_items', to='contributions.contribution')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='featured_items', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['order', '-created_at'], + }, + ), + ] diff --git a/backend/contributions/migrations/0034_featuredcontent_image_fields.py b/backend/contributions/migrations/0034_featuredcontent_image_fields.py new file mode 100644 index 00000000..5055c43d --- /dev/null +++ b/backend/contributions/migrations/0034_featuredcontent_image_fields.py @@ -0,0 +1,31 @@ +# Generated by Django 6.0.2 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contributions', '0033_alter_submissionnote_options_featuredcontent'), + ] + + operations = [ + # Rename hero_image_url -> hero_image_link to free up the name for the serializer + migrations.RenameField( + model_name='featuredcontent', + old_name='hero_image_url', + new_name='hero_image_link', + ), + # Add hero_image FileField for uploaded background images + migrations.AddField( + model_name='featuredcontent', + name='hero_image', + field=models.FileField(blank=True, null=True, upload_to='featured/'), + ), + # Add user_profile_image FileField for user avatar uploads + migrations.AddField( + model_name='featuredcontent', + name='user_profile_image', + field=models.FileField(blank=True, null=True, upload_to='featured/avatars/'), + ), + ] diff --git a/backend/contributions/migrations/0035_remove_featuredcontent_hero_image_and_more.py b/backend/contributions/migrations/0035_remove_featuredcontent_hero_image_and_more.py new file mode 100644 index 00000000..0d37c444 --- /dev/null +++ b/backend/contributions/migrations/0035_remove_featuredcontent_hero_image_and_more.py @@ -0,0 +1,50 @@ +# Generated by Django 6.0.2 on 2026-02-25 02:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contributions', '0034_featuredcontent_image_fields'), + ] + + operations = [ + # Remove FileFields (no longer needed, replaced by Cloudinary URLs) + migrations.RemoveField( + model_name='featuredcontent', + name='hero_image', + ), + migrations.RemoveField( + model_name='featuredcontent', + name='user_profile_image', + ), + # Rename hero_image_link back to hero_image_url (was renamed in migration 0034) + migrations.RenameField( + model_name='featuredcontent', + old_name='hero_image_link', + new_name='hero_image_url', + ), + # Update hero_image_url field definition to match new model + migrations.AlterField( + model_name='featuredcontent', + name='hero_image_url', + field=models.URLField(blank=True, help_text='Cloudinary URL for hero/background image', max_length=500), + ), + # Add new Cloudinary fields + migrations.AddField( + model_name='featuredcontent', + name='hero_image_public_id', + field=models.CharField(blank=True, help_text='Cloudinary public ID for hero image', max_length=255), + ), + migrations.AddField( + model_name='featuredcontent', + name='user_profile_image_url', + field=models.URLField(blank=True, help_text='Cloudinary URL for user avatar in featured card', max_length=500), + ), + migrations.AddField( + model_name='featuredcontent', + name='user_profile_image_public_id', + field=models.CharField(blank=True, help_text='Cloudinary public ID for user avatar', max_length=255), + ), + ] diff --git a/backend/contributions/migrations/0036_alert.py b/backend/contributions/migrations/0036_alert.py new file mode 100644 index 00000000..45176f12 --- /dev/null +++ b/backend/contributions/migrations/0036_alert.py @@ -0,0 +1,33 @@ +# Generated by Django 6.0.2 on 2026-02-26 00:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contributions', '0035_remove_featuredcontent_hero_image_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='Alert', + 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)), + ('alert_type', models.CharField(choices=[('info', 'Info'), ('warning', 'Warning'), ('error', 'Error'), ('success', 'Success')], default='info', max_length=10)), + ('icon', models.CharField(blank=True, help_text='Optional icon name (frontend defaults by type)', max_length=50)), + ('text', models.TextField(help_text='Alert message text')), + ('is_active', models.BooleanField(default=True)), + ('order', models.PositiveIntegerField(default=0, help_text='Display order (lower numbers appear first)')), + ('start_date', models.DateTimeField(blank=True, help_text='When this alert becomes visible (optional)', null=True)), + ('end_date', models.DateTimeField(blank=True, help_text='When this alert expires (optional)', null=True)), + ], + options={ + 'verbose_name': 'Alert', + 'verbose_name_plural': 'Alerts', + 'ordering': ['order', '-created_at'], + }, + ), + ] diff --git a/backend/contributions/models.py b/backend/contributions/models.py index d7458134..9d79eca0 100644 --- a/backend/contributions/models.py +++ b/backend/contributions/models.py @@ -637,4 +637,94 @@ def get_active_requests(cls): """ Get all active startup requests ordered by display order. """ - return cls.objects.filter(is_active=True).order_by('order', '-created_at') \ No newline at end of file + return cls.objects.filter(is_active=True).order_by('order', '-created_at') + + +class FeaturedContent(BaseModel): + """ + Featured content for the home page: hero banners, featured builds, community highlights. + Managed via admin panel. + """ + CONTENT_TYPE_CHOICES = [ + ('hero', 'Hero Banner'), + ('build', 'Featured Build'), + ('community', 'Featured Community'), + ('validator_steward', 'Featured Validator/Steward'), + ] + content_type = models.CharField(max_length=20, choices=CONTENT_TYPE_CHOICES) + title = models.CharField(max_length=200) + description = models.TextField(blank=True) + subtitle = models.CharField(max_length=200, blank=True) + contribution = models.ForeignKey( + 'Contribution', on_delete=models.SET_NULL, null=True, blank=True, + related_name='featured_items' + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, + related_name='featured_items' + ) + hero_image_url = models.URLField(max_length=500, blank=True, help_text="Cloudinary URL for hero/background image") + hero_image_public_id = models.CharField(max_length=255, blank=True, help_text="Cloudinary public ID for hero image") + user_profile_image_url = models.URLField(max_length=500, blank=True, help_text="Cloudinary URL for user avatar in featured card") + user_profile_image_public_id = models.CharField(max_length=255, blank=True, help_text="Cloudinary public ID for user avatar") + url = models.URLField(max_length=500, blank=True) + is_active = models.BooleanField(default=True) + order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ['order', '-created_at'] + + def __str__(self): + return f"{self.get_content_type_display()}: {self.title}" + + def get_link(self): + if self.contribution_id: + return f"/badge/{self.contribution_id}" + return self.url or None + + @classmethod + def get_active_by_type(cls, content_type, limit=10): + return cls.objects.filter( + content_type=content_type, is_active=True + ).select_related( + 'user', 'contribution', 'contribution__contribution_type' + ).order_by('order', '-created_at')[:limit] + + +class Alert(BaseModel): + """ + System-wide alert banners displayed on all pages. + Managed via Django admin. + """ + ALERT_TYPE_CHOICES = [ + ('info', 'Info'), + ('warning', 'Warning'), + ('error', 'Error'), + ('success', 'Success'), + ] + alert_type = models.CharField(max_length=10, choices=ALERT_TYPE_CHOICES, default='info') + icon = models.CharField(max_length=50, blank=True, help_text="Optional icon name (frontend defaults by type)") + text = models.TextField(help_text="Alert message text") + is_active = models.BooleanField(default=True) + order = models.PositiveIntegerField(default=0, help_text="Display order (lower numbers appear first)") + start_date = models.DateTimeField(null=True, blank=True, help_text="When this alert becomes visible (optional)") + end_date = models.DateTimeField(null=True, blank=True, help_text="When this alert expires (optional)") + + class Meta: + ordering = ['order', '-created_at'] + verbose_name = "Alert" + verbose_name_plural = "Alerts" + + def __str__(self): + return f"[{self.get_alert_type_display()}] {self.text[:50]}" + + @classmethod + def get_active_alerts(cls): + now = timezone.now() + return cls.objects.filter( + is_active=True + ).filter( + models.Q(start_date__isnull=True) | models.Q(start_date__lte=now) + ).filter( + models.Q(end_date__isnull=True) | models.Q(end_date__gt=now) + ) \ No newline at end of file diff --git a/backend/contributions/serializers.py b/backend/contributions/serializers.py index a6b3f948..cbd2342b 100644 --- a/backend/contributions/serializers.py +++ b/backend/contributions/serializers.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from .models import ContributionType, Contribution, SubmittedContribution, Evidence, ContributionHighlight, Mission, StartupRequest, SubmissionNote +from .models import ContributionType, Contribution, SubmittedContribution, Evidence, ContributionHighlight, Mission, StartupRequest, SubmissionNote, FeaturedContent, Alert from users.serializers import UserSerializer, LightUserSerializer from users.models import User from .recaptcha_field import ReCaptchaField @@ -716,3 +716,35 @@ class Meta: 'documents', 'is_active', 'order', 'created_at', 'updated_at' ] read_only_fields = ['id', 'created_at', 'updated_at'] + + +class FeaturedContentSerializer(serializers.ModelSerializer): + user_name = serializers.CharField(source='user.name', read_only=True) + user_address = serializers.CharField(source='user.address', read_only=True) + user_profile_image_url = serializers.SerializerMethodField() + link = serializers.SerializerMethodField() + + class Meta: + model = FeaturedContent + fields = ['id', 'content_type', 'title', 'description', 'subtitle', + 'hero_image_url', 'url', 'link', + 'user', 'user_name', 'user_address', 'user_profile_image_url', + 'contribution', 'is_active', 'order', 'created_at'] + + def get_user_profile_image_url(self, obj): + """Return the FeaturedContent's user_profile_image_url if set, otherwise fall back to user's profile_image_url.""" + if obj.user_profile_image_url: + return obj.user_profile_image_url + if obj.user and obj.user.profile_image_url: + return obj.user.profile_image_url + return '' + + def get_link(self, obj): + return obj.get_link() + + +class AlertSerializer(serializers.ModelSerializer): + class Meta: + model = Alert + fields = ['id', 'alert_type', 'icon', 'text', 'order', 'created_at'] + read_only_fields = ['id', 'created_at'] diff --git a/backend/contributions/views.py b/backend/contributions/views.py index fc10bc19..541d5578 100644 --- a/backend/contributions/views.py +++ b/backend/contributions/views.py @@ -7,19 +7,19 @@ from django.db.models import Count, Max, F, Q, Exists, OuterRef, Subquery, Sum from django.db.models.functions import TruncDate, TruncWeek, TruncMonth from django.db.models.functions import Coalesce -from django.db.models.functions import Coalesce from django.shortcuts import render, redirect, get_object_or_404 from django.contrib.auth.decorators import login_required, user_passes_test from django.contrib import messages from django.views.generic import ListView from django.utils.decorators import method_decorator -from .models import ContributionType, Contribution, Evidence, SubmittedContribution, ContributionHighlight, Mission, StartupRequest +from .models import ContributionType, Contribution, Evidence, SubmittedContribution, ContributionHighlight, Mission, StartupRequest, FeaturedContent, Alert from .serializers import (ContributionTypeSerializer, ContributionSerializer, EvidenceSerializer, SubmittedContributionSerializer, SubmittedEvidenceSerializer, ContributionHighlightSerializer, StewardSubmissionSerializer, StewardSubmissionReviewSerializer, SubmissionNoteSerializer, SubmissionProposeSerializer, - MissionSerializer, StartupRequestListSerializer, StartupRequestDetailSerializer) + MissionSerializer, StartupRequestListSerializer, StartupRequestDetailSerializer, + FeaturedContentSerializer, AlertSerializer) from .forms import SubmissionReviewForm from .permissions import IsSteward, steward_has_permission, steward_permitted_type_ids from leaderboard.models import GlobalLeaderboardMultiplier @@ -1597,7 +1597,7 @@ class MissionViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): """ - Return active highlights by default. + Return active missions by default. Supports filtering by contribution_type and category. """ from django.utils import timezone @@ -1645,3 +1645,29 @@ def get_serializer_class(self): if self.action == 'retrieve': return StartupRequestDetailSerializer return StartupRequestListSerializer + + +class FeaturedContentViewSet(viewsets.ReadOnlyModelViewSet): + """Featured content for the portal home page.""" + serializer_class = FeaturedContentSerializer + permission_classes = [permissions.AllowAny] + pagination_class = None + + def get_queryset(self): + queryset = FeaturedContent.objects.filter(is_active=True).select_related( + 'user', 'contribution', 'contribution__contribution_type' + ).order_by('order', '-created_at') + content_type = self.request.query_params.get('type') + if content_type: + queryset = queryset.filter(content_type=content_type) + return queryset + + +class AlertViewSet(viewsets.ReadOnlyModelViewSet): + """Public endpoint for active system alerts.""" + serializer_class = AlertSerializer + permission_classes = [permissions.AllowAny] + pagination_class = None + + def get_queryset(self): + return Alert.get_active_alerts() diff --git a/backend/leaderboard/views.py b/backend/leaderboard/views.py index 14560ee2..81bd114a 100644 --- a/backend/leaderboard/views.py +++ b/backend/leaderboard/views.py @@ -2,11 +2,11 @@ from rest_framework.decorators import action from rest_framework.response import Response from django.utils import timezone -from django.db.models import Count, Q +from django.db.models import Count, Q, Sum from django_filters.rest_framework import DjangoFilterBackend from .models import GlobalLeaderboardMultiplier, LeaderboardEntry, update_all_ranks, recalculate_all_leaderboards, LEADERBOARD_CONFIG from .serializers import GlobalLeaderboardMultiplierSerializer, LeaderboardEntrySerializer -from contributions.models import Category +from contributions.models import Category, Contribution class GlobalLeaderboardMultiplierViewSet(viewsets.ReadOnlyModelViewSet): @@ -216,10 +216,29 @@ def stats(self, request): total=Sum('frozen_global_points') )['total'] or 0 + # Category-specific counts (always included) + builder_count = LeaderboardEntry.objects.filter( + type='builder', user__visible=True + ).count() + validator_count = LeaderboardEntry.objects.filter( + type='validator', user__visible=True + ).count() + + from .models import ReferralPoints + from django.db.models import F + creator_count = ReferralPoints.objects.filter( + user__visible=True + ).annotate( + total_pts=F('builder_points') + F('validator_points') + ).filter(total_pts__gt=0).count() + return Response({ 'participant_count': participant_count, 'contribution_count': contribution_count, 'total_points': total_points, + 'builder_count': builder_count, + 'validator_count': validator_count, + 'creator_count': creator_count, }) def _get_user_stats(self, user, category=None): @@ -403,9 +422,9 @@ def types(self, request): return Response(types_info) @action(detail=False, methods=['get']) - def supporters(self, request): + def community(self, request): """ - Get supporters statistics and all supporters with referral points. + Get community statistics and all community members with referral points. Returns users with referrals sorted by total referral points. """ from .models import ReferralPoints @@ -418,15 +437,15 @@ def supporters(self, request): ).filter(user__visible=True, total_points__gt=0).order_by('-total_points') # Calculate aggregate stats - total_supporters = referral_points.count() + total_community = referral_points.count() total_builder_points = sum(rp.builder_points for rp in referral_points) total_validator_points = sum(rp.validator_points for rp in referral_points) - # Get all supporters - top_supporters = [] + # Get all community members + top_community = [] for rp in referral_points: user_data = UserSerializer(rp.user).data - top_supporters.append({ + top_community.append({ **user_data, 'builder_points': rp.builder_points, 'validator_points': rp.validator_points, @@ -434,9 +453,71 @@ def supporters(self, request): }) return Response({ - 'total_supporters': total_supporters, + 'total_community': total_community, 'total_builder_points': total_builder_points, 'total_validator_points': total_validator_points, - 'top_supporters': top_supporters + 'top_community': top_community }) + + @action(detail=False, methods=['get']) + def trending(self, request): + """ + Get users who earned the most points recently. + Tries last 30 days first; falls back to all-time if no data. + """ + try: + limit = int(request.query_params.get('limit', 10)) + except (ValueError, TypeError): + limit = 10 + limit = min(max(limit, 1), 100) + + from datetime import timedelta + from users.models import User + + cutoff = timezone.now() - timedelta(days=30) + + # Aggregate points per user from last 30 days + trending_users = list( + Contribution.objects.filter( + created_at__gte=cutoff, + user__visible=True, + ) + .values('user_id') + .annotate(total_points=Sum('frozen_global_points')) + .order_by('-total_points')[:limit] + ) + + # Fall back to all-time if no recent data + if not trending_users: + trending_users = list( + Contribution.objects.filter(user__visible=True) + .values('user_id') + .annotate(total_points=Sum('frozen_global_points')) + .order_by('-total_points')[:limit] + ) + + user_ids = [entry['user_id'] for entry in trending_users] + points_map = {entry['user_id']: entry['total_points'] for entry in trending_users} + + users = User.objects.filter(id__in=user_ids).select_related( + 'builder', 'validator', 'steward' + ) + users_by_id = {u.id: u for u in users} + + results = [] + for user_id in user_ids: + user = users_by_id.get(user_id) + if not user: + continue + results.append({ + 'user_name': user.name or '', + 'user_address': user.address or '', + 'profile_image_url': user.profile_image_url or '', + 'total_points': points_map[user_id] or 0, + 'builder': hasattr(user, 'builder'), + 'validator': hasattr(user, 'validator'), + 'steward': hasattr(user, 'steward'), + }) + + return Response(results) diff --git a/backend/users/cloudinary_service.py b/backend/users/cloudinary_service.py index 8e5c1f2e..1da9e361 100644 --- a/backend/users/cloudinary_service.py +++ b/backend/users/cloudinary_service.py @@ -149,6 +149,86 @@ def upload_banner_image(cls, image_file, user_id: int) -> Dict: ) raise + @classmethod + def upload_featured_image(cls, image_file, featured_id) -> Dict: + """ + Upload a featured content hero/background image to Cloudinary. + + Args: + image_file: The image file to upload + featured_id: The FeaturedContent ID for unique naming + + Returns: + Dict with 'url' and 'public_id' + """ + try: + cls.configure() + + upload_preset = getattr(settings, 'CLOUDINARY_UPLOAD_PRESET', 'tally_unsigned') + timestamp = int(time.time()) + + with trace_external('cloudinary', 'upload_featured_image'): + result = cloudinary.uploader.unsigned_upload( + image_file, + upload_preset, + public_id=f"featured_{featured_id}_hero_{timestamp}", + folder="tally/featured" + ) + + return { + 'url': result.get('secure_url', ''), + 'public_id': result.get('public_id', ''), + } + + except Exception as e: + logger.error(f"Failed to upload featured image: {str(e)}") + if "Upload preset not found" in str(e): + raise Exception( + "Cloudinary upload preset not configured. Please create an unsigned upload preset " + "named 'tally_unsigned' in your Cloudinary dashboard (Settings > Upload > Upload presets)." + ) + raise + + @classmethod + def upload_featured_avatar(cls, image_file, featured_id) -> Dict: + """ + Upload a featured content user avatar to Cloudinary. + + Args: + image_file: The image file to upload + featured_id: The FeaturedContent ID for unique naming + + Returns: + Dict with 'url' and 'public_id' + """ + try: + cls.configure() + + upload_preset = getattr(settings, 'CLOUDINARY_UPLOAD_PRESET', 'tally_unsigned') + timestamp = int(time.time()) + + with trace_external('cloudinary', 'upload_featured_avatar'): + result = cloudinary.uploader.unsigned_upload( + image_file, + upload_preset, + public_id=f"featured_{featured_id}_avatar_{timestamp}", + folder="tally/featured/avatars" + ) + + return { + 'url': result.get('secure_url', ''), + 'public_id': result.get('public_id', ''), + } + + except Exception as e: + logger.error(f"Failed to upload featured avatar: {str(e)}") + if "Upload preset not found" in str(e): + raise Exception( + "Cloudinary upload preset not configured. Please create an unsigned upload preset " + "named 'tally_unsigned' in your Cloudinary dashboard (Settings > Upload > Upload presets)." + ) + raise + @classmethod def delete_image(cls, public_id: str) -> bool: """ diff --git a/backend/users/views.py b/backend/users/views.py index 6eff19e9..ce2fb71f 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -1,4 +1,4 @@ -from rest_framework import viewsets, permissions, status +from rest_framework import viewsets, permissions, status, filters from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated @@ -32,6 +32,8 @@ class UserViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = UserSerializer permission_classes = [permissions.AllowAny] # Allow read-only access without authentication lookup_field = 'address' # Change default lookup field from 'pk' to 'address' + filter_backends = [filters.OrderingFilter] + ordering_fields = ['date_joined', 'created_at'] def get_object(self): """ @@ -56,6 +58,9 @@ def get_serializer_context(self): context['use_light_serializers'] = self.action == 'list' # Include referral_details only for detail/by_address views context['include_referral_details'] = self.action in ['retrieve', 'by_address'] + # Pass visible flag for create action + if self.action == 'create' and 'visible' in self.request.data: + context['visible'] = self.request.data.get('visible') return context @action(detail=False, methods=['get'], url_path='by-address/(?P
[^/.]+)') @@ -111,12 +116,6 @@ def get_serializer_class(self): return UserCreateSerializer return UserSerializer - def get_serializer_context(self): - context = super().get_serializer_context() - if self.action == 'create' and 'visible' in self.request.data: - context['visible'] = self.request.data.get('visible') - return context - @action(detail=False, methods=['get', 'patch'], permission_classes=[permissions.IsAuthenticated]) def me(self, request): """ @@ -772,7 +771,7 @@ def referrals(self, request): 'referrals': referral_list }) - @action(detail=False, methods=['get'], permission_classes=[IsAuthenticated]) + @action(detail=False, methods=['get'], permission_classes=[permissions.AllowAny]) def search(self, request): """Search users by name, address, email, or social handles.""" query = request.query_params.get('q', '').strip() diff --git a/backend/validators/views.py b/backend/validators/views.py index 179f2e0c..8f55347c 100644 --- a/backend/validators/views.py +++ b/backend/validators/views.py @@ -97,6 +97,7 @@ def newest_validators(self, request): user = users_dict.get(validator['user']) if user: user_data = LightUserSerializer(user).data + user_data['validator'] = True user_data['first_uptime_date'] = validator['first_uptime_date'] result.append(user_data) else: @@ -104,6 +105,7 @@ def newest_validators(self, request): result.append({ 'address': validator['user__address'], 'name': validator['user__name'], + 'validator': True, 'first_uptime_date': validator['first_uptime_date'] }) diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 071b366c..1b477428 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -194,12 +194,20 @@ The application uses custom variable fonts for better visual hierarchy: - Use `font-body` class only in exceptional cases (very rare) - Supports all weights and italic variants +**Display/Numbers:** F37 Lineca VF (variable font, all weights) +- Use `font-display` class for large display text and stat numbers +- Used in: "Points" in header, hero titles ("Argue.fun Launch"), LiveStats numbers, PointsAwarded titles, CTAFooter headline +- Falls back to Geist if F37 Lineca fails to load +- Variable font supports all weights (`font-light` through `font-bold`) +- Figma uses Medium weight β€” use `font-medium` with `font-display` + **To change fonts globally:** Update CSS custom properties in `src/styles.css`: ```css :root { --font-heading: 'Geist', sans-serif; --font-body: 'Switzer', sans-serif; + --font-display: 'F37 Lineca', 'Geist', sans-serif; } ``` @@ -211,8 +219,8 @@ Update CSS custom properties in `src/styles.css`: // βœ… Title in non-heading element - add font-heading
Card Title
-// βœ… Large stat values - add font-heading for emphasis -

{totalPoints}

+// βœ… Large display numbers - use font-display (F37 Lineca) +

{totalPoints}

// βœ… Body text - automatic font-body (no class needed)

Description text

@@ -556,18 +564,16 @@ Components like `RecentContributions`, `HighlightedContributions`, `UserContribu The only exception is when you need to group multiple related elements that aren't already in a component. ### Gradients Policy -**NO GRADIENTS**: Avoid using gradient backgrounds in the UI. Use solid colors instead for a cleaner, more professional look. +Gradients are allowed when they match the Figma design. Use Tailwind gradient utilities (`bg-gradient-to-r`, etc.) to implement them. ```javascript -// ❌ WRONG - Don't use gradients +// βœ… CORRECT - Use gradients when they match the Figma design
-// βœ… CORRECT - Use solid colors +// βœ… CORRECT - Solid colors are also fine where appropriate
``` -Gradients can make text harder to read and create visual complexity. Stick to the established color palette with solid colors for consistency. - ## Important Notes ### Evidence Submission diff --git a/frontend/public/assets/featured-builds/argue-fun-bg.jpg b/frontend/public/assets/featured-builds/argue-fun-bg.jpg new file mode 100644 index 00000000..d3786951 Binary files /dev/null and b/frontend/public/assets/featured-builds/argue-fun-bg.jpg differ diff --git a/frontend/public/assets/featured-builds/arrow-right-up-line.svg b/frontend/public/assets/featured-builds/arrow-right-up-line.svg new file mode 100644 index 00000000..e222227d --- /dev/null +++ b/frontend/public/assets/featured-builds/arrow-right-up-line.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/featured-builds/cognocracy-avatar.png b/frontend/public/assets/featured-builds/cognocracy-avatar.png new file mode 100644 index 00000000..7520548e Binary files /dev/null and b/frontend/public/assets/featured-builds/cognocracy-avatar.png differ diff --git a/frontend/public/assets/featured-builds/genlayer-avatar.png b/frontend/public/assets/featured-builds/genlayer-avatar.png new file mode 100644 index 00000000..8bf6729c Binary files /dev/null and b/frontend/public/assets/featured-builds/genlayer-avatar.png differ diff --git a/frontend/public/assets/featured-builds/internet-court-bg.jpg b/frontend/public/assets/featured-builds/internet-court-bg.jpg new file mode 100644 index 00000000..9d7c7208 Binary files /dev/null and b/frontend/public/assets/featured-builds/internet-court-bg.jpg differ diff --git a/frontend/public/assets/featured-builds/rally-bg.jpg b/frontend/public/assets/featured-builds/rally-bg.jpg new file mode 100644 index 00000000..190ab3e9 Binary files /dev/null and b/frontend/public/assets/featured-builds/rally-bg.jpg differ diff --git a/frontend/public/assets/featured-builds/raskovsky-avatar.png b/frontend/public/assets/featured-builds/raskovsky-avatar.png new file mode 100644 index 00000000..84b173fd Binary files /dev/null and b/frontend/public/assets/featured-builds/raskovsky-avatar.png differ diff --git a/frontend/public/assets/gl-logo-black.svg b/frontend/public/assets/gl-logo-black.svg new file mode 100644 index 00000000..6bb68170 --- /dev/null +++ b/frontend/public/assets/gl-logo-black.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/assets/gl-logo-points.svg b/frontend/public/assets/gl-logo-points.svg new file mode 100644 index 00000000..771418bd --- /dev/null +++ b/frontend/public/assets/gl-logo-points.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/frontend/public/assets/gl-symbol-black.svg b/frontend/public/assets/gl-symbol-black.svg new file mode 100644 index 00000000..d5bdc207 --- /dev/null +++ b/frontend/public/assets/gl-symbol-black.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/hero-bg.png b/frontend/public/assets/hero-bg.png new file mode 100644 index 00000000..66f003b4 Binary files /dev/null and b/frontend/public/assets/hero-bg.png differ diff --git a/frontend/public/assets/icons/add-line-sidebar.svg b/frontend/public/assets/icons/add-line-sidebar.svg new file mode 100644 index 00000000..d89fec17 --- /dev/null +++ b/frontend/public/assets/icons/add-line-sidebar.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/assets/icons/add-line.svg b/frontend/public/assets/icons/add-line.svg new file mode 100644 index 00000000..4aa19c61 --- /dev/null +++ b/frontend/public/assets/icons/add-line.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/assets/icons/arrow-left-s-line.svg b/frontend/public/assets/icons/arrow-left-s-line.svg new file mode 100644 index 00000000..0ab3641e --- /dev/null +++ b/frontend/public/assets/icons/arrow-left-s-line.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/icons/arrow-right-line.svg b/frontend/public/assets/icons/arrow-right-line.svg new file mode 100644 index 00000000..389ddae2 --- /dev/null +++ b/frontend/public/assets/icons/arrow-right-line.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/assets/icons/arrow-right-s-line-expand.svg b/frontend/public/assets/icons/arrow-right-s-line-expand.svg new file mode 100644 index 00000000..90aa7581 --- /dev/null +++ b/frontend/public/assets/icons/arrow-right-s-line-expand.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/icons/arrow-right-s-line.svg b/frontend/public/assets/icons/arrow-right-s-line.svg new file mode 100644 index 00000000..8d38561a --- /dev/null +++ b/frontend/public/assets/icons/arrow-right-s-line.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/assets/icons/arrow-right-up-line.svg b/frontend/public/assets/icons/arrow-right-up-line.svg new file mode 100644 index 00000000..048f1505 --- /dev/null +++ b/frontend/public/assets/icons/arrow-right-up-line.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/assets/icons/arrow-up-s-line.svg b/frontend/public/assets/icons/arrow-up-s-line.svg new file mode 100644 index 00000000..f78951bc --- /dev/null +++ b/frontend/public/assets/icons/arrow-up-s-line.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/icons/dashboard-fill-black.svg b/frontend/public/assets/icons/dashboard-fill-black.svg new file mode 100644 index 00000000..1e5dfe2c --- /dev/null +++ b/frontend/public/assets/icons/dashboard-fill-black.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/assets/icons/dashboard-fill.svg b/frontend/public/assets/icons/dashboard-fill.svg new file mode 100644 index 00000000..ce258477 --- /dev/null +++ b/frontend/public/assets/icons/dashboard-fill.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/assets/icons/folder-shield-line-blue.svg b/frontend/public/assets/icons/folder-shield-line-blue.svg new file mode 100644 index 00000000..af6786c4 --- /dev/null +++ b/frontend/public/assets/icons/folder-shield-line-blue.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/assets/icons/folder-shield-line.svg b/frontend/public/assets/icons/folder-shield-line.svg new file mode 100644 index 00000000..d860d673 --- /dev/null +++ b/frontend/public/assets/icons/folder-shield-line.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/assets/icons/gl-symbol-white.svg b/frontend/public/assets/icons/gl-symbol-white.svg new file mode 100644 index 00000000..2eb0be46 --- /dev/null +++ b/frontend/public/assets/icons/gl-symbol-white.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/frontend/public/assets/icons/group-3-line-purple.svg b/frontend/public/assets/icons/group-3-line-purple.svg new file mode 100644 index 00000000..9250536b --- /dev/null +++ b/frontend/public/assets/icons/group-3-line-purple.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/assets/icons/group-3-line.svg b/frontend/public/assets/icons/group-3-line.svg new file mode 100644 index 00000000..d9be6e9c --- /dev/null +++ b/frontend/public/assets/icons/group-3-line.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/assets/icons/group-white.svg b/frontend/public/assets/icons/group-white.svg new file mode 100644 index 00000000..18ecad58 --- /dev/null +++ b/frontend/public/assets/icons/group-white.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/icons/hexagon-builder-light.svg b/frontend/public/assets/icons/hexagon-builder-light.svg new file mode 100644 index 00000000..5aff58b1 --- /dev/null +++ b/frontend/public/assets/icons/hexagon-builder-light.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/icons/hexagon-builder.svg b/frontend/public/assets/icons/hexagon-builder.svg new file mode 100644 index 00000000..64ce6bd8 --- /dev/null +++ b/frontend/public/assets/icons/hexagon-builder.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/icons/hexagon-community-light.svg b/frontend/public/assets/icons/hexagon-community-light.svg new file mode 100644 index 00000000..b13071b8 --- /dev/null +++ b/frontend/public/assets/icons/hexagon-community-light.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/icons/hexagon-community.svg b/frontend/public/assets/icons/hexagon-community.svg new file mode 100644 index 00000000..ad5de055 --- /dev/null +++ b/frontend/public/assets/icons/hexagon-community.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/icons/hexagon-genlayer.svg b/frontend/public/assets/icons/hexagon-genlayer.svg new file mode 100644 index 00000000..5aa19e02 --- /dev/null +++ b/frontend/public/assets/icons/hexagon-genlayer.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/icons/hexagon-light.svg b/frontend/public/assets/icons/hexagon-light.svg new file mode 100644 index 00000000..eda103db --- /dev/null +++ b/frontend/public/assets/icons/hexagon-light.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/icons/hexagon-steward-light.svg b/frontend/public/assets/icons/hexagon-steward-light.svg new file mode 100644 index 00000000..11b6d346 --- /dev/null +++ b/frontend/public/assets/icons/hexagon-steward-light.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/icons/hexagon-steward.svg b/frontend/public/assets/icons/hexagon-steward.svg new file mode 100644 index 00000000..d8957963 --- /dev/null +++ b/frontend/public/assets/icons/hexagon-steward.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/icons/hexagon-validator-light.svg b/frontend/public/assets/icons/hexagon-validator-light.svg new file mode 100644 index 00000000..4711721a --- /dev/null +++ b/frontend/public/assets/icons/hexagon-validator-light.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/icons/hexagon-validator.svg b/frontend/public/assets/icons/hexagon-validator.svg new file mode 100644 index 00000000..06ffe285 --- /dev/null +++ b/frontend/public/assets/icons/hexagon-validator.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/assets/icons/search-line.svg b/frontend/public/assets/icons/search-line.svg new file mode 100644 index 00000000..e0ca2ee4 --- /dev/null +++ b/frontend/public/assets/icons/search-line.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/icons/seedling-line-green.svg b/frontend/public/assets/icons/seedling-line-green.svg new file mode 100644 index 00000000..76c5181b --- /dev/null +++ b/frontend/public/assets/icons/seedling-line-green.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/assets/icons/seedling-line.svg b/frontend/public/assets/icons/seedling-line.svg new file mode 100644 index 00000000..e1d581f3 --- /dev/null +++ b/frontend/public/assets/icons/seedling-line.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/assets/icons/shield-white.svg b/frontend/public/assets/icons/shield-white.svg new file mode 100644 index 00000000..9e3002d3 --- /dev/null +++ b/frontend/public/assets/icons/shield-white.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/assets/icons/terminal-fill-white.svg b/frontend/public/assets/icons/terminal-fill-white.svg new file mode 100644 index 00000000..55f696aa --- /dev/null +++ b/frontend/public/assets/icons/terminal-fill-white.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/frontend/public/assets/icons/terminal-line-orange.svg b/frontend/public/assets/icons/terminal-line-orange.svg new file mode 100644 index 00000000..3481a711 --- /dev/null +++ b/frontend/public/assets/icons/terminal-line-orange.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/assets/icons/terminal-line.svg b/frontend/public/assets/icons/terminal-line.svg new file mode 100644 index 00000000..c33cb08f --- /dev/null +++ b/frontend/public/assets/icons/terminal-line.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/public/assets/icons/verified-badge-fill.svg b/frontend/public/assets/icons/verified-badge-fill.svg new file mode 100644 index 00000000..3a1b29b4 --- /dev/null +++ b/frontend/public/assets/icons/verified-badge-fill.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/public/fonts/F37Lineca-VF.ttf b/frontend/public/fonts/F37Lineca-VF.ttf new file mode 100644 index 00000000..6bd1f7d9 Binary files /dev/null and b/frontend/public/fonts/F37Lineca-VF.ttf differ diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 32041c57..412cbf89 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -10,9 +10,10 @@ import { categoryTheme, currentCategory, detectCategoryFromRoute } from './stores/category.js'; import { location } from 'svelte-spa-router'; - // State for sidebar toggle on mobile + // State for sidebar toggle on mobile and collapse on desktop let sidebarOpen = $state(false); - + let sidebarCollapsed = $state(false); + function toggleSidebar() { sidebarOpen = !sidebarOpen; } @@ -45,9 +46,10 @@ import TermsOfUse from './routes/TermsOfUse.svelte'; import PrivacyPolicy from './routes/PrivacyPolicy.svelte'; import Referrals from './routes/Referrals.svelte'; - import Supporters from './routes/Supporters.svelte'; + import Community from './routes/Community.svelte'; import GlobalDashboard from './components/GlobalDashboard.svelte'; import StartupRequestDetail from './routes/StartupRequestDetail.svelte'; + import SystemAlerts from './components/portal/SystemAlerts.svelte'; // Define routes const routes = { @@ -66,7 +68,7 @@ '/leaderboard': Leaderboard, '/participants': Validators, '/referrals': Referrals, - '/supporters': Supporters, + '/community': Community, // Builders routes '/builders': Dashboard, @@ -285,17 +287,9 @@
- -
- -
- - - -

- Contribution reviews are currently delayed. All submissions are being recorded and will be reviewed shortly. Thank you for your patience and continued participation. -

-
+ +
+ import { push, location } from 'svelte-spa-router'; import AuthButton from './AuthButton.svelte'; - import ReferralSection from './ReferralSection.svelte'; import SearchBar from './SearchBar.svelte'; - import Icon from './Icons.svelte'; import { authState } from '../lib/auth.js'; - import { categoryTheme, currentCategory } from '../stores/category.js'; let { toggleSidebar, sidebarOpen = false } = $props(); @@ -38,55 +35,50 @@ } -
-
- -
+
+
+ + - - -
- - -
- - -
+ + + + + +
+ +
\ No newline at end of file diff --git a/frontend/src/components/ProfileCompletionGuard.svelte b/frontend/src/components/ProfileCompletionGuard.svelte index e55919c6..1b9bd582 100644 --- a/frontend/src/components/ProfileCompletionGuard.svelte +++ b/frontend/src/components/ProfileCompletionGuard.svelte @@ -14,7 +14,7 @@ let hasExistingEmail = $state(false); // Determine if profile is incomplete - let showGuard = $derived(() => { + let showGuard = $derived.by(() => { // Don't show while loading if ($authState.loading || $userStore.loading) return false; @@ -37,7 +37,7 @@ // Pre-fill form fields when user data is available $effect(() => { const user = $userStore.user; - if (user && showGuard()) { + if (user && showGuard) { // Pre-fill name if it exists if (user.name && user.name.trim() !== '') { name = user.name; @@ -116,7 +116,7 @@ } -{#if showGuard()} +{#if showGuard}
diff --git a/frontend/src/components/SearchBar.svelte b/frontend/src/components/SearchBar.svelte index 3bfa3224..e2ffd031 100644 --- a/frontend/src/components/SearchBar.svelte +++ b/frontend/src/components/SearchBar.svelte @@ -114,22 +114,10 @@
- - - + .search-container { position: relative; - width: 280px; + width: 200px; } .search-input-wrapper { position: relative; display: flex; align-items: center; + background-color: #f5f5f5; + border: 1.2px solid #e6e6e6; + border-radius: 20px; + height: 40px; + padding: 0 16px; + gap: 8px; } .search-icon { - position: absolute; - left: 0.75rem; width: 1rem; height: 1rem; - color: #9ca3af; + flex-shrink: 0; pointer-events: none; } .search-input { width: 100%; - padding: 0.5rem 0.75rem 0.5rem 2.25rem; - border: 1px solid #e5e7eb; - border-radius: 0.5rem; + padding: 0; + border: none; + border-radius: 0; font-size: 0.875rem; - background-color: #f9fafb; - transition: all 0.2s; + font-weight: 500; + background-color: transparent; + height: 100%; + letter-spacing: 0.28px; } .search-input:focus { outline: none; - border-color: #2563eb; - background-color: white; - box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); + background-color: transparent; + border-color: transparent; + box-shadow: none; } .search-input::placeholder { - color: #9ca3af; + color: #ababab; } .loading-spinner { - position: absolute; - right: 0.75rem; width: 1rem; height: 1rem; border: 2px solid #e5e7eb; border-radius: 50%; border-top-color: #2563eb; animation: spin 0.8s linear infinite; + flex-shrink: 0; } @keyframes spin { diff --git a/frontend/src/components/Sidebar.svelte b/frontend/src/components/Sidebar.svelte index c3ab105a..20596065 100644 --- a/frontend/src/components/Sidebar.svelte +++ b/frontend/src/components/Sidebar.svelte @@ -1,39 +1,16 @@ - diff --git a/frontend/src/components/portal/AlertBanner.svelte b/frontend/src/components/portal/AlertBanner.svelte new file mode 100644 index 00000000..6b61adc2 --- /dev/null +++ b/frontend/src/components/portal/AlertBanner.svelte @@ -0,0 +1,85 @@ + + + + + diff --git a/frontend/src/components/portal/CTAFooter.svelte b/frontend/src/components/portal/CTAFooter.svelte new file mode 100644 index 00000000..33fe0083 --- /dev/null +++ b/frontend/src/components/portal/CTAFooter.svelte @@ -0,0 +1,24 @@ + + +
+

+ Start contributing today +

+

+ Join professional validators and builders in creating the trust infrastructure for the AI age. +

+
+ +
+
diff --git a/frontend/src/components/portal/CategoryIcon.svelte b/frontend/src/components/portal/CategoryIcon.svelte new file mode 100644 index 00000000..fa2321a9 --- /dev/null +++ b/frontend/src/components/portal/CategoryIcon.svelte @@ -0,0 +1,47 @@ + + +{#if mode === 'hexagon'} +
+ + +
+{:else} + +{/if} diff --git a/frontend/src/components/portal/FeaturedBuilds.svelte b/frontend/src/components/portal/FeaturedBuilds.svelte new file mode 100644 index 00000000..e5546540 --- /dev/null +++ b/frontend/src/components/portal/FeaturedBuilds.svelte @@ -0,0 +1,148 @@ + + +
+
+
+

Featured Builds

+

This month curated builds

+
+
+ Explore all + +
+
+ + {#if loading} +
+ {#each [1, 2, 3] as _} +
+ {/each} +
+ {:else} + + {/if} +
diff --git a/frontend/src/components/portal/HeroBanner.svelte b/frontend/src/components/portal/HeroBanner.svelte new file mode 100644 index 00000000..a3c5bdb0 --- /dev/null +++ b/frontend/src/components/portal/HeroBanner.svelte @@ -0,0 +1,79 @@ + + +{#if loading} + +
+
+
+
+
+
+
+
+
+
+{:else} +
+ +
+ +
+
+ + +
+
+
+ By {displayData.subtitle || displayData.user_name || 'Unknown'} + Verified +
+

+ {displayData.title} +

+

+ {displayData.description} +

+
+ + +
+
+{/if} diff --git a/frontend/src/components/portal/LiveStats.svelte b/frontend/src/components/portal/LiveStats.svelte new file mode 100644 index 00000000..a3bd4acd --- /dev/null +++ b/frontend/src/components/portal/LiveStats.svelte @@ -0,0 +1,109 @@ + + +
+
+
+

GenLayer Live

+ +
+

What's going on today in GenLayer?

+
+ + {#if loading} +
+ {#each [1, 2, 3, 4] as _} +
+
+
+
+
+
+
+ {/each} +
+ {:else if error} +
+ Failed to load stats +
+ {:else} +
+ {#each statConfigs as stat} +
+ +
+
+ +
+
+

{stat.value}

+

{stat.label}

+
+
+ + +
+ + {stat.delta} +
+
+ {/each} +
+ {/if} +
diff --git a/frontend/src/components/portal/MiniLeaderboard.svelte b/frontend/src/components/portal/MiniLeaderboard.svelte new file mode 100644 index 00000000..dcdc320b --- /dev/null +++ b/frontend/src/components/portal/MiniLeaderboard.svelte @@ -0,0 +1,306 @@ + + + +
+
+

Top Point Contributors

+

This month curated builds

+
+ + {#if loading} + +
+ {#each [1, 2, 3] as _} +
+
+
+
+
+
+
+
+
+
+
+
+ {#each [1, 2, 3, 4, 5] as __} +
+
+
+ {/each} +
+
+ {#each [1, 2, 3, 4, 5] as __} +
+
+
+
+ {/each} +
+
+
+ {#each [1, 2, 3, 4, 5] as __} +
+
+
+ {/each} +
+
+
+
+ {/each} +
+ {:else if error} +
Could not load leaderboard
+ {:else} + +
+ + {@render column('Builders', builders, '/builders', 'builder')} + + {@render column('Validators', validators, '/validators', 'validator')} + + {@render communityColumn()} +
+ {/if} +
+ +{#snippet column(label, entries, viewPath, type)} +
+ +
+
+ {@render labelIcon(type)} + {label} +
+ +
+ + +
+ {#if entries.length === 0} +
No data
+ {:else} +
+ +
+ +
+ {#each entries as _, i} +
+ {i + 1} +
+ {/each} +
+ +
+ {#each entries as entry} + + {/each} +
+
+ + +
+ +
+ {#each entries as entry} +
+ + + + {formatPoints(entry.total_points ?? entry.points)} +
+ {/each} +
+ +
+ {#each entries as entry} +
+ {formatPoints(entry.total_points ?? entry.points)} +
+ {/each} +
+
+
+ {/if} +
+
+{/snippet} + +{#snippet communityColumn()} +
+ +
+
+ {@render labelIcon('community')} + Community +
+ +
+ + +
+ {#if community.length === 0} +
No data
+ {:else} +
+ +
+ +
+ {#each community as _, i} +
+ {i + 1} +
+ {/each} +
+ +
+ {#each community as entry} + + {/each} +
+
+ + +
+ +
+ {#each community as entry} +
+ + + + {formatPoints(entry.total_points ?? entry.points)} +
+ {/each} +
+ +
+ {#each community as entry} +
+ {formatPoints(entry.builder_points ?? 0)} +
+ {/each} +
+ +
+ {#each community as entry} +
+ {formatPoints(entry.validator_points ?? 0)} +
+ {/each} +
+
+
+ {/if} +
+
+{/snippet} + +{#snippet labelIcon(type)} + {@const cfg = categoryConfig[type]} +
+ +
+
+{/snippet} + diff --git a/frontend/src/components/portal/NewestMembers.svelte b/frontend/src/components/portal/NewestMembers.svelte new file mode 100644 index 00000000..e6a8f384 --- /dev/null +++ b/frontend/src/components/portal/NewestMembers.svelte @@ -0,0 +1,215 @@ + + +
+ +
+
+

Newest members

+

New

+ + +
+ {#each tabs as tab} + + {/each} +
+
+ + + +
+ + + {#if loading} +
+ {#each [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14] as _} +
+
+
+
+
+
+
+
+
+
+
+
+ {/each} +
+ {:else if error} +
Could not load members
+ {:else if members.length === 0} +
No members yet
+ {:else} +
+ {#each members as member} + + {/each} +
+ {/if} +
diff --git a/frontend/src/components/portal/PointsAwarded.svelte b/frontend/src/components/portal/PointsAwarded.svelte new file mode 100644 index 00000000..2339782d --- /dev/null +++ b/frontend/src/components/portal/PointsAwarded.svelte @@ -0,0 +1,70 @@ + + +
+ +
+
+
+
+ +
+ +

Points Program

+ + +

+ 1 Million +

+

+ Limited Supply +

+

+ Total GenLayer Points available for contributors +

+ + +
+
+ Distributed + {distributedPct}% of supply +
+
+
+
+
+ + +
+
+ {formatNumber(DISTRIBUTED)} + Distributed +
+
+ {REMAINING_PCT}% + Remaining +
+
+ ${AVG_GP} + Avg GP per contribution +
+
+
+
diff --git a/frontend/src/components/portal/PortalHighlights.svelte b/frontend/src/components/portal/PortalHighlights.svelte new file mode 100644 index 00000000..678cafa4 --- /dev/null +++ b/frontend/src/components/portal/PortalHighlights.svelte @@ -0,0 +1,130 @@ + + +
+
+
+

Highlighted Contributions

+

Outstanding community contributions

+
+ +
+ + {#if loading} +
+ {#each [1, 2, 3] as _} +
+ {/each} +
+ {:else if highlights.length === 0} +
+
+ No highlights yet +
+
+ {:else} +
+ {#each highlights as highlight} + {@const category = highlight.contribution_type_category || 'validator'} + {@const colors = getCategoryColors(category)} + + {/each} +
+ {/if} +
diff --git a/frontend/src/components/portal/SystemAlerts.svelte b/frontend/src/components/portal/SystemAlerts.svelte new file mode 100644 index 00000000..f57287a2 --- /dev/null +++ b/frontend/src/components/portal/SystemAlerts.svelte @@ -0,0 +1,68 @@ + + +{#if visibleAlerts.length > 0} +
+ {#each visibleAlerts as alert (alert.id)} + + {/each} +
+{/if} diff --git a/frontend/src/components/portal/TrendingContributors.svelte b/frontend/src/components/portal/TrendingContributors.svelte new file mode 100644 index 00000000..9892e921 --- /dev/null +++ b/frontend/src/components/portal/TrendingContributors.svelte @@ -0,0 +1,147 @@ + + +
+ +
+
+

Trending Contributors

+

Highest GenLayer Points Contributions this week

+
+ +
+ + + {#if loading} +
+ {#each [1, 2, 3, 4, 5, 6] as _} +
+
+
+
+
+
+
+
+
+ {/each} +
+ {:else if error} +
Could not load contributors
+ {:else if contributors.length === 0} +
No contributors yet
+ {:else} +
+ {#each contributors as entry} + + {/each} +
+ {/if} +
diff --git a/frontend/src/components/portal/components.md b/frontend/src/components/portal/components.md new file mode 100644 index 00000000..008e02b1 --- /dev/null +++ b/frontend/src/components/portal/components.md @@ -0,0 +1,154 @@ +# Portal Components + +Reusable components for the GL Portal Home page (`/` route, `Overview.svelte`). + +## CategoryIcon + +**File**: `CategoryIcon.svelte` + +Reusable icon component with two display modes for category icons. + +### Props +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `category` | `string` | `'genlayer'` | One of: `genlayer`, `builder`, `validator`, `community` | +| `mode` | `string` | `'small'` | Display mode: `small` (16px black icon) or `hexagon` (48px gradient hexagon with white icon) | +| `size` | `number` | `undefined` | Override default size in px | + +### Usage +```svelte + + + + + + + + +``` + +### Variants +- **genlayer**: Dark gradient hexagon + GL symbol (white) / Dashboard icon (black) +- **builder**: Orange gradient hexagon + Terminal icon (white) / Terminal icon (black) +- **validator**: Blue gradient hexagon + Shield icon (white) / Folder-shield icon (black) +- **community**: Purple gradient hexagon + Group icon (white) / Group icon (black) + +--- + +## HeroBanner + +**File**: `HeroBanner.svelte` + +Featured project showcase with background image, gradient overlay, and info card. + +- Background: `hero-bg.png` with gradient overlay +- Card: Glassmorphic card with project info, verified badge, CTA button +- Static content (will be wired to API later) + +--- + +## LiveStats + +**File**: `LiveStats.svelte` + +Live statistics dashboard with 4 stat cards in a grid. + +- Title: "GenLayer Live" with green dot indicator +- Subtitle: "What's going on today in GenLayer?" +- Cards: Hexagon gradient icon (via `CategoryIcon`), stat value, label, delta indicator +- Data source: `statsAPI.getDashboardStats()` + +--- + +## PointsAwarded + +**File**: `PointsAwarded.svelte` + +Dark-themed card showing total points supply and distribution progress bar. + +- Background: #131214 +- Gradient progress bar from purple to orange +- Stat pills for distributed/remaining + +--- + +## TrendingContributors + +**File**: `TrendingContributors.svelte` + +Horizontal scrolling list of trending contributor cards. + +- Data source: `leaderboardAPI.getLeaderboard()` +- Shows avatar, name, points, rank + +--- + +## FeaturedBuilds + +**File**: `FeaturedBuilds.svelte` + +Grid of featured project cards (placeholder, needs API). + +--- + +## NewestMembers + +**File**: `NewestMembers.svelte` + +Tabbed member grid with pill-style tab navigation. + +- Tabs: All, Builders, Validators, Community +- Data source: `usersAPI.getUsers()` + +--- + +## MiniLeaderboard + +**File**: `MiniLeaderboard.svelte` + +Three side-by-side top-5 leaderboard columns. + +- Columns: Builders, Validators, Community +- Category-colored point values +- Data source: `leaderboardAPI.getBuilders()`, `.getValidators()`, `.getCommunity()` + +--- + +## CTAFooter + +**File**: `CTAFooter.svelte` + +Call-to-action footer with "Start contributing today" message and dark button. + +--- + +## SVG Assets + +All icons are in `/public/assets/icons/`. Key assets: + +### Hexagon Backgrounds +- `hexagon-genlayer.svg` - Dark gradient (GenLayer) +- `hexagon-builder.svg` - Orange gradient (Builder) +- `hexagon-validator.svg` - Blue gradient (Validator) +- `hexagon-community.svg` - Purple gradient (Community) + +### White Icons (for hexagon overlay) +- `gl-symbol-white.svg` - GenLayer logo symbol +- `terminal-fill-white.svg` - Terminal/builder icon +- `shield-white.svg` - Shield/validator icon +- `group-white.svg` - Group/community icon + +### Black Icons (for sidebar) +- `dashboard-fill.svg` / `dashboard-fill-black.svg` - Dashboard (active purple / inactive black) +- `terminal-line.svg` - Builder +- `folder-shield-line.svg` - Validator +- `group-3-line.svg` - Community +- `seedling-line.svg` - Steward + +### UI Icons +- `search-line.svg` - Search magnifying glass +- `add-line.svg` / `add-line-sidebar.svg` - Plus icon (white / gray) +- `arrow-up-s-line.svg` - Up arrow (green, for deltas) +- `arrow-right-line.svg` - Right arrow +- `arrow-left-s-line.svg` / `arrow-right-s-line.svg` / `arrow-right-s-line-expand.svg` - Sidebar collapse/expand +- `verified-badge-fill.svg` - Purple verified checkmark badge diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 8cb2d73b..da6ce7bc 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -113,7 +113,8 @@ export const leaderboardAPI = { getStats: () => api.get('/leaderboard/stats/'), getWaitlistStats: () => api.get('/leaderboard/validator-waitlist-stats/'), getWaitlistTop: (limit = 10) => api.get('/leaderboard/validator-waitlist/top/', { params: { limit } }), - getSupporters: () => api.get('/leaderboard/supporters/'), + getCommunity: () => api.get('/leaderboard/community/'), + getTrending: (limit = 10) => api.get('/leaderboard/trending/', { params: { limit } }), getTypes: () => api.get('/leaderboard/types/'), recalculateAll: () => api.post('/leaderboard/recalculate/') }; @@ -153,7 +154,7 @@ export const journeyAPI = { completeBuilderJourney: () => api.post('/users/complete_builder_journey/') }; -// Supporter API (backend uses 'creator' terminology) +// Community API (backend uses 'creator' terminology) export const creatorAPI = { joinAsCreator: () => api.post('/creators/join/') }; @@ -239,4 +240,18 @@ export const updateUserProfile = async (data) => { }; +// Featured content API +export const featuredAPI = { + getFeatured: (params) => api.get('/featured/', { params }), + getHero: () => api.get('/featured/', { params: { type: 'hero' } }), + getBuilds: () => api.get('/featured/', { params: { type: 'build' } }), + getCommunity: () => api.get('/featured/', { params: { type: 'community' } }), + getValidatorsStewards: () => api.get('/featured/', { params: { type: 'validator_steward' } }), +}; + +// Alerts API +export const alertsAPI = { + getAlerts: () => api.get('/alerts/'), +}; + export default api; \ No newline at end of file diff --git a/frontend/src/routes/Supporters.svelte b/frontend/src/routes/Community.svelte similarity index 84% rename from frontend/src/routes/Supporters.svelte rename to frontend/src/routes/Community.svelte index a274d808..59888c49 100644 --- a/frontend/src/routes/Supporters.svelte +++ b/frontend/src/routes/Community.svelte @@ -10,43 +10,43 @@ // Get current user from store let user = $derived($userStore.user); - // Check if user is already a supporter (has creator profile) - let isSupporter = $derived(!!user?.creator); + // Check if user is already a community member (has creator profile) + let isCommunityMember = $derived(!!user?.creator); // State management - let supporters = $state([]); + let communityMembers = $state([]); let loading = $state(true); let error = $state(null); - let joiningSupporter = $state(false); + let joiningCommunity = $state(false); - // Fetch supporters data - async function fetchSupporters() { + // Fetch community data + async function fetchCommunity() { try { loading = true; error = null; - const response = await leaderboardAPI.getSupporters(); + const response = await leaderboardAPI.getCommunity(); const data = response.data; - supporters = data.top_supporters || []; + communityMembers = data.top_community || []; loading = false; } catch (err) { - error = err.message || 'Failed to load supporters'; + error = err.message || 'Failed to load community members'; loading = false; } } onMount(() => { - fetchSupporters(); + fetchCommunity(); }); - async function joinAsSupporter() { + async function joinAsCommunity() { if (!$authState.isAuthenticated || !user?.address) { return; } try { - joiningSupporter = true; + joiningCommunity = true; error = null; const response = await creatorAPI.joinAsCreator(); @@ -59,9 +59,9 @@ push(`/participant/${user.address}`); } } catch (err) { - error = err.response?.data?.message || 'Failed to join as supporter'; + error = err.response?.data?.message || 'Failed to join the community'; } finally { - joiningSupporter = false; + joiningCommunity = false; } } @@ -75,9 +75,9 @@
-

Supporters

+

Community

- +
@@ -85,7 +85,7 @@
-

Top Supporters

+

Top Community Members

{#if loading} @@ -96,14 +96,14 @@

{error}

- {:else if supporters.length === 0} + {:else if communityMembers.length === 0}
-

No Supporters Yet

+

No Community Members Yet

Be the first to earn referral points by inviting people to the GenLayer ecosystem!

{:else} @@ -124,7 +124,7 @@ - {#each supporters as supporter, i} + {#each communityMembers as member, i} @@ -135,30 +135,30 @@
- {supporter.total_points} + {member.total_points}
- {supporter.builder_points} + {member.builder_points}
- {supporter.validator_points} + {member.validator_points}
@@ -172,8 +172,8 @@ {/if}
- - {#if $authState.isAuthenticated && user && !isSupporter} + + {#if $authState.isAuthenticated && user && !isCommunityMember}
@@ -183,14 +183,14 @@
-

Become a Top Supporter

+

Become a Top Community Member

Grow the GenLayer community and earn rewards

-

Supporters help grow the GenLayer ecosystem by inviting others to join as Builders or Validators. The more your referrals contribute, the more points you earn!

+

Community members help grow the GenLayer ecosystem by inviting others to join as Builders or Validators. The more your referrals contribute, the more points you earn!

  • @@ -220,18 +220,18 @@ {/if}
    Joining... {:else} - Become a Supporter + Join the Community {/if}
@@ -247,9 +247,9 @@
-

About Supporters

+

About the Community

-

Supporters help grow the GenLayer ecosystem by inviting others to join as Builders or Validators.

+

Community members help grow the GenLayer ecosystem by inviting others to join as Builders or Validators.

  • Earn points when your referrals complete contributions
  • Track Builder and Validator referral points separately
  • diff --git a/frontend/src/routes/Overview.svelte b/frontend/src/routes/Overview.svelte index 8aa0487d..41cbf8f2 100644 --- a/frontend/src/routes/Overview.svelte +++ b/frontend/src/routes/Overview.svelte @@ -1,188 +1,21 @@ -
    - -
    -

    GenLayer’s Incentivized Builder Program

    -

    - - The GenLayer Testnet Incentives Program rewards early contributors who -help build the foundation of the GenLayer ecosystem across code, -infrastructure, and community growth. Participation is open to everyone. Direct -coding is not required to participate. -

    -

    - Community members can earn points by referring builders. The referral -points will show as β€œBuilder Points” in the Supporters section as soon -as the referred Builders complete the Builders Welcome Journey or their first -contribution. -

    -

    - All contributions are tracked through the wallet address, which is required to receive points and maintain contribution records. Contributors can view their rank and track points in real time through the leaderboards and individual profile pages. Apart from the builders program, GenLayer also incentivizes validator contributions. -

    -
    - - -
    - -
    -

    - - - - Referral Program -

    -
    - - -
    - -
    - Builder Program -
    - - -
    -

    - For each builder referred who submits at least one contribution, the referrer receives 10% of the points that builder earns permanently. -

    - {#if $authState.isAuthenticated} -
    - -
    - {:else} -
    - -
    - {/if} -
    -
    -
    - - -
    -

    Contributor Categories

    - -
    - -
    - -
    -

    - - - - Builders -

    -
    - -
    -

    - Contribute by writing Intelligent Contracts, launching dApps, creating educational - resources, or building developer tools. -

    - -
    -
    - - -
    - -
    -

    - - Supporters -

    -
    - -
    -

    - Non-technical contributors who grow the community. Currently earn points through - referring builders and validators. Additional opportunities will become available in - future phases. -

    -

    - Note: Supporter activity does not currently influence Discord roles or points. -

    -
    -
    - - -
    - -
    -

    - - - - Validators -

    -
    - -
    -

    - Help scale and secure the GenLayer network. Points can be earned through contributions like documentation, protocol testing, infrastructure tooling, security research, and regional validator deployment. -

    -
    - - -
    -
    -
    - - -
    - -
    -

    - 🌱 - Stewards -

    -
    - -
    -

    - Stewards review submitted contributions, evaluating them on technical quality, originality, and ecosystem impact. Stewards assign points transparently and may request revisions or offer feedback to improve contributions. Their role ensures fairness, consistency, and alignment with GenLayer's long-term vision as the ecosystem grows. -

    - -
    -
    -
    -
    +
    + + + + + + + +
    diff --git a/frontend/src/routes/Profile.svelte b/frontend/src/routes/Profile.svelte index 2514925a..c16dee34 100644 --- a/frontend/src/routes/Profile.svelte +++ b/frontend/src/routes/Profile.svelte @@ -276,14 +276,14 @@ // If successful, reload the user data if (response.status === 201 || response.status === 200) { // Show a success toast - showSuccess('You are now a Supporter! Start growing the community through referrals.'); + showSuccess('You are now a Community Member! Start growing the community through referrals.'); - // Reload participant data to get Supporter profile (same pattern as Builder) + // Reload participant data to get Community profile (same pattern as Builder) const updatedUser = await getCurrentUser(); participant = updatedUser; } } catch (err) { - error = err.response?.data?.message || 'Failed to join as supporter'; + error = err.response?.data?.message || 'Failed to join the community'; } } @@ -560,7 +560,7 @@ {/if} {#if participant.creator} - Supporter + Community {/if} {/if} @@ -1533,7 +1533,7 @@
- +
@@ -1545,7 +1545,7 @@
-

Become a Supporter

+

Join the Community

Grow the GenLayer community through referrals and earn rewards

@@ -1570,7 +1570,7 @@ - Become a Supporter + Join the Community
diff --git a/frontend/src/routes/ProfileEdit.svelte b/frontend/src/routes/ProfileEdit.svelte index 84e5575c..0efa15d9 100644 --- a/frontend/src/routes/ProfileEdit.svelte +++ b/frontend/src/routes/ProfileEdit.svelte @@ -237,11 +237,11 @@ const response = await creatorAPI.joinAsCreator(); if (response.status === 201 || response.status === 200) { // Store success message and reload - sessionStorage.setItem('journeySuccess', 'You are now a Supporter! Start growing the community through referrals.'); + sessionStorage.setItem('journeySuccess', 'You are now a Community Member! Start growing the community through referrals.'); loadUserData(); } } catch (err) { - error = err.response?.data?.message || 'Failed to join as supporter'; + error = err.response?.data?.message || 'Failed to join the community'; } } @@ -750,8 +750,8 @@ {@const needsBuilder = !user.builder && !user.has_builder_welcome} {@const needsValidator = !user.validator && !user.has_validator_waitlist} {@const needsSteward = !user.steward} - {@const needsSupporter = !user.creator} - {@const inactiveCount = [needsBuilder, needsValidator, needsSteward, needsSupporter].filter(Boolean).length} + {@const needsCommunity = !user.creator} + {@const inactiveCount = [needsBuilder, needsValidator, needsSteward, needsCommunity].filter(Boolean).length} {#if needsBuilder || needsValidator}
@@ -791,10 +791,10 @@
{/if} - - {#if needsSteward || needsSupporter} - {@const bothStewardSupporter = needsSteward && needsSupporter} -
+ + {#if needsSteward || needsCommunity} + {@const bothStewardCommunity = needsSteward && needsCommunity} +
{#if needsSteward}
@@ -807,14 +807,14 @@
{/if} - - {#if needsSupporter} + + {#if needsCommunity}

- Supporter + Community

Focus on growing the community through referrals.

Earn 10% of points from every contribution your referrals make.

@@ -822,23 +822,23 @@ onclick={startCreatorJourney} class="px-3 py-1.5 bg-purple-600 text-white rounded text-sm hover:bg-purple-700 transition-colors" > - Become a Supporter β†’ + Join the Community β†’
{/if}
{/if} - + {#if user.creator}

- Supporter + Community

-

You're a Supporter! Focus on growing the community through referrals.

+

You're a Community Member! Focus on growing the community through referrals.

Earn 10% of points from every contribution your referrals make.

{/if} diff --git a/frontend/src/stores/category.js b/frontend/src/stores/category.js index 36c2e7a8..68f6cf65 100644 --- a/frontend/src/stores/category.js +++ b/frontend/src/stores/category.js @@ -26,8 +26,8 @@ export const categories = [ iconPath: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z' }, { - id: 'supporter', - name: 'Supporters', + id: 'community', + name: 'Community', iconPath: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z' } ]; @@ -95,7 +95,7 @@ export const categoryTheme = derived(currentCategory, $category => { button: 'bg-green-600 hover:bg-green-700 text-white', buttonLight: 'bg-green-100 hover:bg-green-200 text-green-700' }, - supporter: { + community: { // Purple theme bg: 'bg-purple-50', bgSecondary: 'bg-purple-100', @@ -131,8 +131,8 @@ export function detectCategoryFromRoute(path) { return 'validator'; } else if (path.startsWith('/stewards')) { return 'steward'; - } else if (path.startsWith('/supporters')) { - return 'supporter'; + } else if (path.startsWith('/community')) { + return 'community'; } return 'global'; } \ No newline at end of file diff --git a/frontend/src/styles.css b/frontend/src/styles.css index c9bd8696..b0d0ac44 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -10,6 +10,14 @@ font-display: swap; } +@font-face { + font-family: 'F37 Lineca'; + src: url('/fonts/F37Lineca-VF.ttf') format('truetype-variations'); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} + @font-face { font-family: 'Switzer'; src: url('/fonts/Switzer-Variable.woff2') format('woff2-variations'); @@ -29,6 +37,11 @@ :root { --font-heading: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; --font-body: 'Switzer', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --font-display: 'F37 Lineca', 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + +.font-display { + font-family: var(--font-display); } body { diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 4d20c132..47875a5d 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -7,6 +7,7 @@ export default { heading: ['var(--font-heading)'], body: ['var(--font-body)'], sans: ['var(--font-body)'], + display: ['var(--font-display)'], }, colors: { primary: {