diff --git a/backend/Makefile b/backend/Makefile index 9eda82fb15..7731affb5a 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -102,6 +102,10 @@ owasp-update-events: @echo "Getting OWASP events data" @CMD="python manage.py owasp_update_events" $(MAKE) exec-backend-command +owasp-update-sponsors: + @echo "Getting OWASP sponsors data" + @CMD="python manage.py owasp_update_sponsors" $(MAKE) exec-backend-command + purge-data: @CMD="python manage.py purge_data" $(MAKE) exec-backend-command @@ -136,4 +140,5 @@ update-data: \ owasp-scrape-projects \ github-update-project-related-repositories \ owasp-aggregate-projects \ - owasp-update-events + owasp-update-events \ + owasp-update-sponsors diff --git a/backend/apps/owasp/admin.py b/backend/apps/owasp/admin.py index 3bdf2a75d3..c6aabd1c20 100644 --- a/backend/apps/owasp/admin.py +++ b/backend/apps/owasp/admin.py @@ -10,6 +10,7 @@ from apps.owasp.models.project_health_metrics import ProjectHealthMetrics from apps.owasp.models.project_health_requirements import ProjectHealthRequirements from apps.owasp.models.snapshot import Snapshot +from apps.owasp.models.sponsor import Sponsor class GenericEntityAdminMixin: @@ -141,6 +142,36 @@ class SnapshotAdmin(admin.ModelAdmin): ) +class SponsorAdmin(admin.ModelAdmin): + """Admin configuration for Sponsor model.""" + + list_display = ( + "name", + "sort_name", + "sponsor_type", + "is_member", + "member_type", + ) + + search_fields = ( + "name", + "sort_name", + "description", + ) + + list_filter = ( + "sponsor_type", + "is_member", + "member_type", + ) + + fieldsets = ( + ("Basic Information", {"fields": ("name", "sort_name", "description")}), + ("URLs and Images", {"fields": ("url", "job_url", "image_url")}), + ("Status", {"fields": ("is_member", "member_type", "sponsor_type")}), + ) + + admin.site.register(Chapter, ChapterAdmin) admin.site.register(Committee, CommitteeAdmin) admin.site.register(Event, EventAdmin) @@ -148,3 +179,4 @@ class SnapshotAdmin(admin.ModelAdmin): admin.site.register(ProjectHealthMetrics) admin.site.register(ProjectHealthRequirements) admin.site.register(Snapshot, SnapshotAdmin) +admin.site.register(Sponsor, SponsorAdmin) diff --git a/backend/apps/owasp/graphql/nodes/sponsor.py b/backend/apps/owasp/graphql/nodes/sponsor.py new file mode 100644 index 0000000000..fec3b30409 --- /dev/null +++ b/backend/apps/owasp/graphql/nodes/sponsor.py @@ -0,0 +1,16 @@ +"""OWASP sponsors GraphQL node.""" + +from apps.common.graphql.nodes import BaseNode +from apps.owasp.models.sponsor import Sponsor + + +class SponsorNode(BaseNode): + """Sponsor node.""" + + class Meta: + model = Sponsor + fields = ( + "image_url", + "name", + "url", + ) diff --git a/backend/apps/owasp/graphql/queries/__init__.py b/backend/apps/owasp/graphql/queries/__init__.py index 871bd1c3dc..538bdd9a1c 100644 --- a/backend/apps/owasp/graphql/queries/__init__.py +++ b/backend/apps/owasp/graphql/queries/__init__.py @@ -5,10 +5,11 @@ from .event import EventQuery from .project import ProjectQuery from .snapshot import SnapshotQuery +from .sponsor import SponsorQuery from .stats import StatsQuery class OwaspQuery( - ChapterQuery, CommitteeQuery, EventQuery, ProjectQuery, SnapshotQuery, StatsQuery + ChapterQuery, CommitteeQuery, EventQuery, ProjectQuery, SnapshotQuery, SponsorQuery, StatsQuery ): """OWASP queries.""" diff --git a/backend/apps/owasp/graphql/queries/sponsor.py b/backend/apps/owasp/graphql/queries/sponsor.py new file mode 100644 index 0000000000..d81ed2d8ff --- /dev/null +++ b/backend/apps/owasp/graphql/queries/sponsor.py @@ -0,0 +1,17 @@ +"""OWASP sponsors GraphQL queries.""" + +import graphene + +from apps.common.graphql.queries import BaseQuery +from apps.owasp.graphql.nodes.sponsor import SponsorNode +from apps.owasp.models.sponsor import Sponsor + + +class SponsorQuery(BaseQuery): + """Sponsor queries.""" + + sponsors = graphene.List(SponsorNode) + + def resolve_sponsors(root, info): + """Resolve sponsors.""" + return Sponsor.objects.all() diff --git a/backend/apps/owasp/management/commands/owasp_update_sponsors.py b/backend/apps/owasp/management/commands/owasp_update_sponsors.py new file mode 100644 index 0000000000..f7c52aece3 --- /dev/null +++ b/backend/apps/owasp/management/commands/owasp_update_sponsors.py @@ -0,0 +1,20 @@ +"""A command to add OWASP sponsors data.""" + +import yaml +from django.core.management.base import BaseCommand + +from apps.github.utils import get_repository_file_content +from apps.owasp.models.sponsor import Sponsor + + +class Command(BaseCommand): + help = "Import sponsors from the provided YAML file" + + def handle(self, *args, **kwargs): + sponsors = yaml.safe_load( + get_repository_file_content( + "https://raw.githubusercontent.com/OWASP/owasp.github.io/main/_data/corp_members.yml" + ).expandtabs() + ) + + Sponsor.bulk_save([Sponsor.update_data(sponsor) for sponsor in sponsors]) diff --git a/backend/apps/owasp/migrations/0022_sponsor.py b/backend/apps/owasp/migrations/0022_sponsor.py new file mode 100644 index 0000000000..618c90eb40 --- /dev/null +++ b/backend/apps/owasp/migrations/0022_sponsor.py @@ -0,0 +1,85 @@ +# Generated by Django 5.1.5 on 2025-03-04 15:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("owasp", "0021_alter_snapshot_key"), + ] + + operations = [ + migrations.CreateModel( + name="Sponsor", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("nest_created_at", models.DateTimeField(auto_now_add=True)), + ("nest_updated_at", models.DateTimeField(auto_now=True)), + ( + "description", + models.TextField(blank=True, verbose_name="Description"), + ), + ( + "key", + models.CharField(max_length=100, unique=True, verbose_name="Key"), + ), + ("name", models.CharField(max_length=255, verbose_name="Name")), + ( + "sort_name", + models.CharField(max_length=255, verbose_name="Sort Name"), + ), + ("url", models.URLField(blank=True, verbose_name="Website URL")), + ("job_url", models.URLField(blank=True, verbose_name="Job URL")), + ( + "image_url", + models.CharField(blank=True, max_length=255, verbose_name="Image Path"), + ), + ( + "is_member", + models.BooleanField(default=False, verbose_name="Is Corporate Sponsor"), + ), + ( + "member_type", + models.CharField( + blank=True, + choices=[ + ("Platinum", "Platinum"), + ("Gold", "Gold"), + ("Silver", "Silver"), + ], + default="Silver", + max_length=20, + verbose_name="Member Type", + ), + ), + ( + "sponsor_type", + models.CharField( + choices=[ + ("Diamond", "Diamond"), + ("Platinum", "Platinum"), + ("Gold", "Gold"), + ("Silver", "Silver"), + ("Supporter", "Supporter"), + ("Not a Sponsor", "Not Sponsor"), + ], + default="Not a Sponsor", + max_length=20, + verbose_name="Sponsor Type", + ), + ), + ], + options={ + "verbose_name_plural": "Sponsors", + "db_table": "owasp_sponsors", + }, + ), + ] diff --git a/backend/apps/owasp/models/sponsor.py b/backend/apps/owasp/models/sponsor.py new file mode 100644 index 0000000000..bb1f1c436f --- /dev/null +++ b/backend/apps/owasp/models/sponsor.py @@ -0,0 +1,131 @@ +"""OWASP app sponsor models.""" + +from django.db import models + +from apps.common.models import BulkSaveModel, TimestampedModel +from apps.common.utils import slugify +from apps.github.utils import normalize_url + + +class Sponsor(BulkSaveModel, TimestampedModel): + """Sponsor model.""" + + objects = models.Manager() + + class Meta: + db_table = "owasp_sponsors" + verbose_name_plural = "Sponsors" + + class SponsorType(models.TextChoices): + DIAMOND = "Diamond" + PLATINUM = "Platinum" + GOLD = "Gold" + SILVER = "Silver" + SUPPORTER = "Supporter" + NOT_SPONSOR = "Not a Sponsor" + + class MemberType(models.TextChoices): + PLATINUM = "Platinum" + GOLD = "Gold" + SILVER = "Silver" + + # Basic information + description = models.TextField(verbose_name="Description", blank=True) + key = models.CharField(verbose_name="Key", max_length=100, unique=True) + name = models.CharField(verbose_name="Name", max_length=255) + sort_name = models.CharField(verbose_name="Sort Name", max_length=255) + + # URLs and images + url = models.URLField(verbose_name="Website URL", blank=True) + job_url = models.URLField(verbose_name="Job URL", blank=True) + image_url = models.CharField(verbose_name="Image Path", max_length=255, blank=True) + + # Status fields + is_member = models.BooleanField(verbose_name="Is Corporate Sponsor", default=False) + member_type = models.CharField( + verbose_name="Member Type", + max_length=20, + choices=MemberType.choices, + default=MemberType.SILVER, + blank=True, + ) + sponsor_type = models.CharField( + verbose_name="Sponsor Type", + max_length=20, + choices=SponsorType.choices, + default=SponsorType.NOT_SPONSOR, + ) + + def __str__(self): + """Sponsor human readable representation.""" + return f"{self.name}" + + @property + def readable_member_type(self): + """Get human-readable member type.""" + return self.MemberType(self.member_type).label + + @property + def readable_sponsor_type(self): + """Get human-readable sponsor type.""" + return self.SponsorType(self.sponsor_type).label + + @staticmethod + def bulk_save(sponsors, fields=None): + """Bulk save sponsors.""" + BulkSaveModel.bulk_save(Sponsor, sponsors, fields=fields) + + @staticmethod + def update_data(data, save=True): + """Update sponsor data.""" + key = slugify(data["name"]) + try: + sponsor = Sponsor.objects.get(key=key) + except Sponsor.DoesNotExist: + sponsor = Sponsor(key=key) + + sponsor.from_dict(data) + + if save: + sponsor.save() + + return sponsor + + def from_dict(self, data): + """Update instance based on the dict data.""" + image_path = data.get("image", "").lstrip("/") + image_url = f"https://raw.githubusercontent.com/OWASP/owasp.github.io/main/{image_path}" + + member_type_mapping = { + 2: self.MemberType.PLATINUM, + 3: self.MemberType.GOLD, + 4: self.MemberType.SILVER, + } + sponsor_type_mapping = { + -1: self.SponsorType.NOT_SPONSOR, + 1: self.SponsorType.DIAMOND, + 2: self.SponsorType.PLATINUM, + 3: self.SponsorType.GOLD, + 4: self.SponsorType.SILVER, + 5: self.SponsorType.SUPPORTER, + } + + member_key = data.get("membertype", 4) + sponsor_key = data.get("sponsor", -1) + member_type_label = member_type_mapping.get(member_key, self.MemberType.SILVER) + sponsor_type_label = sponsor_type_mapping.get(sponsor_key, self.SponsorType.NOT_SPONSOR) + + fields = { + "description": data.get("description", ""), + "image_url": image_url, + "is_member": data.get("member", False), + "job_url": normalize_url(data.get("job_url", "")) or "", + "member_type": member_type_label, + "name": data["name"], + "sort_name": data.get("sortname", ""), + "sponsor_type": sponsor_type_label, + "url": normalize_url(data.get("url", "")) or "", + } + + for key, value in fields.items(): + setattr(self, key, value) diff --git a/backend/apps/slack/commands/sponsors.py b/backend/apps/slack/commands/sponsors.py index dbecd5d8b5..90229ed505 100644 --- a/backend/apps/slack/commands/sponsors.py +++ b/backend/apps/slack/commands/sponsors.py @@ -2,10 +2,11 @@ from django.conf import settings -from apps.common.constants import NL +from apps.common.constants import NL, OWASP_WEBSITE_URL from apps.slack.apps import SlackConfig from apps.slack.blocks import markdown -from apps.slack.utils import get_text +from apps.slack.constants import FEEDBACK_CHANNEL_MESSAGE +from apps.slack.utils import get_sponsors_data, get_text COMMAND = "/sponsors" @@ -13,15 +14,38 @@ def sponsors_handler(ack, command, client): """Slack /sponsors command handler.""" ack() - if not settings.SLACK_COMMANDS_ENABLED: return - blocks = [ + sponsors = get_sponsors_data() + if not sponsors: + client.chat_postMessage( + channel=command["user_id"], text="Failed to get OWASP sponsor data." + ) + return + + blocks = [] + blocks.append(markdown("*OWASP Sponsors:*")) + + for idx, sponsor in enumerate(sponsors, start=1): + if sponsor.url: + block_text = f"*{idx}. <{sponsor.url}|{sponsor.name}>*{NL}" + else: + block_text = f"*{idx}. {sponsor.name}*{NL}" + + block_text += f"Member Type: {sponsor.member_type}{NL}" + block_text += f"{sponsor.description}{NL}" + + blocks.append(markdown(block_text)) + + blocks.append({"type": "divider"}) + blocks.append( markdown( - f"Please visit page{NL}" - ), - ] + f"* Please visit the <{OWASP_WEBSITE_URL}/supporters|OWASP supporters>" + f" for more information about the sponsors*{NL}" + f"{FEEDBACK_CHANNEL_MESSAGE}" + ) + ) conversation = client.conversations_open(users=command["user_id"]) client.chat_postMessage( diff --git a/backend/apps/slack/utils.py b/backend/apps/slack/utils.py index 84ddbed382..8cdef4b424 100644 --- a/backend/apps/slack/utils.py +++ b/backend/apps/slack/utils.py @@ -85,7 +85,7 @@ def get_staff_data(timeout=30): def get_events_data(): - """Get raw events data via GraphQL.""" + """Get events data.""" from apps.owasp.models.event import Event try: @@ -95,6 +95,17 @@ def get_events_data(): return None +def get_sponsors_data(limit=10): + """Get sponsors data.""" + from apps.owasp.models.sponsor import Sponsor + + try: + return Sponsor.objects.all()[:limit] + except Exception as e: + logger.exception("Failed to fetch sponsors data via database", extra={"error": str(e)}) + return None + + def get_text(blocks): """Convert blocks to plain text.""" text = [] diff --git a/backend/tests/owasp/models/sponsors_test.py b/backend/tests/owasp/models/sponsors_test.py new file mode 100644 index 0000000000..ac354425ea --- /dev/null +++ b/backend/tests/owasp/models/sponsors_test.py @@ -0,0 +1,93 @@ +from unittest.mock import Mock, patch + +import pytest + +from apps.owasp.models.sponsor import Sponsor + + +class TestSponsorModel: + @pytest.mark.parametrize( + ("name", "expected_str"), + [ + ("Test Sponsor", "Test Sponsor"), + ("", ""), + ], + ) + def test_str_representation(self, name, expected_str): + """Test the __str__ method of the Sponsor model.""" + sponsor = Sponsor(name=name) + assert str(sponsor) == expected_str + + @pytest.mark.parametrize( + ("sponsor_type", "expected_label"), + [ + ("Diamond", "Diamond"), + ("Platinum", "Platinum"), + ("Gold", "Gold"), + ("Silver", "Silver"), + ("Supporter", "Supporter"), + ("Not a Sponsor", "Not Sponsor"), + ], + ) + def test_readable_sponsor_type(self, sponsor_type, expected_label): + """Test the readable_sponsor_type property.""" + sponsor = Sponsor(sponsor_type=sponsor_type) + assert sponsor.readable_sponsor_type == expected_label + + @pytest.mark.parametrize( + ("member_type", "expected_label"), + [ + ("Platinum", "Platinum"), + ("Gold", "Gold"), + ("Silver", "Silver"), + ], + ) + def test_readable_member_type(self, member_type, expected_label): + """Test the readable_member_type property.""" + sponsor = Sponsor(member_type=member_type) + assert sponsor.readable_member_type == expected_label + + def test_bulk_save(self): + """Test the bulk_save method.""" + mock_sponsors = [Mock(id=None), Mock(id=1)] + with patch("apps.owasp.models.sponsor.BulkSaveModel.bulk_save") as mock_bulk_save: + Sponsor.bulk_save(mock_sponsors, fields=["name"]) + mock_bulk_save.assert_called_once_with(Sponsor, mock_sponsors, fields=["name"]) + + @pytest.mark.parametrize( + ("sponsor_type_value", "expected_sponsor_type"), + [ + (-1, "Not a Sponsor"), + (1, "Diamond"), + (2, "Platinum"), + ], + ) + def test_from_dict_sponsor_type_mapping(self, sponsor_type_value, expected_sponsor_type): + """Test the from_dict method for sponsor_type mapping.""" + sponsor = Sponsor() + sponsor.from_dict( + { + "name": "Sponsor", + "sponsor": sponsor_type_value, + } + ) + assert sponsor.sponsor_type == expected_sponsor_type + + @pytest.mark.parametrize( + ("member_type_value", "expected_member_type"), + [ + (2, "Platinum"), + (3, "Gold"), + (4, "Silver"), + ], + ) + def test_from_dict_member_type_mapping(self, member_type_value, expected_member_type): + """Test the from_dict method for member_type mapping.""" + sponsor = Sponsor() + sponsor.from_dict( + { + "membertype": member_type_value, + "name": "Sponsor", + } + ) + assert sponsor.member_type == expected_member_type diff --git a/backend/tests/slack/commands/sponsors_test.py b/backend/tests/slack/commands/sponsors_test.py new file mode 100644 index 0000000000..f464b1ee8b --- /dev/null +++ b/backend/tests/slack/commands/sponsors_test.py @@ -0,0 +1,118 @@ +"""Test sponsors command handler.""" + +from unittest.mock import MagicMock, patch + +import pytest +from django.conf import settings + +from apps.slack.commands.sponsors import sponsors_handler + + +class MockSponsor: + def __init__(self, name, member_type, description, url): + self.name = name + self.member_type = member_type + self.description = description + self.url = url + + +mock_sponsors = [ + MockSponsor( + name="Example Sponsor 1", + member_type="Platinum", + description="A top-tier sponsor.", + url="https://example.com/sponsor1", + ), + MockSponsor( + name="Example Sponsor 2", + member_type="Gold", + description="A mid-tier sponsor.", + url="https://example.com/sponsor2", + ), +] + + +class TestSponsorsHandler: + """Test sponsors command handler.""" + + @pytest.fixture() + def mock_slack_command(self): + return { + "user_id": "U123456", + } + + @pytest.fixture() + def mock_slack_client(self): + client = MagicMock() + client.conversations_open.return_value = {"channel": {"id": "C123456"}} + return client + + @pytest.mark.parametrize( + ("commands_enabled", "has_sponsors_data", "expected_header"), + [ + (False, True, None), + (True, True, "*OWASP Sponsors:*"), + (True, False, "*OWASP Sponsors:*"), + ], + ) + @patch("apps.slack.commands.sponsors.get_sponsors_data") + def test_handler_responses( + self, + mock_get_sponsors_data, + commands_enabled, + has_sponsors_data, + expected_header, + mock_slack_client, + mock_slack_command, + ): + """Test handler responses.""" + settings.SLACK_COMMANDS_ENABLED = commands_enabled + mock_get_sponsors_data.return_value = mock_sponsors if has_sponsors_data else [] + + sponsors_handler(ack=MagicMock(), command=mock_slack_command, client=mock_slack_client) + + if not commands_enabled: + mock_slack_client.conversations_open.assert_not_called() + mock_slack_client.chat_postMessage.assert_not_called() + return + + if not has_sponsors_data: + mock_slack_client.chat_postMessage.assert_called_once_with( + channel=mock_slack_command["user_id"], + text="Failed to get OWASP sponsor data.", + ) + mock_slack_client.conversations_open.assert_not_called() + return + + mock_slack_client.conversations_open.assert_called_once_with( + users=mock_slack_command["user_id"] + ) + + blocks = mock_slack_client.chat_postMessage.call_args[1]["blocks"] + + assert blocks[0]["text"]["text"] == expected_header + + if has_sponsors_data: + current_block = 1 + + sponsor_block = blocks[current_block]["text"]["text"] + assert "*1. *" in sponsor_block + assert "Member Type: Platinum" in sponsor_block + assert "A top-tier sponsor." in sponsor_block + current_block += 1 + + sponsor_block = blocks[current_block]["text"]["text"] + assert "*2. *" in sponsor_block + assert "Member Type: Gold" in sponsor_block + assert "A mid-tier sponsor." in sponsor_block + current_block += 1 + + assert blocks[current_block]["type"] == "divider" + current_block += 1 + + footer_block = blocks[current_block]["text"]["text"] + assert ( + "* Please visit the " + in footer_block + ) + assert "for more information about the sponsors" in footer_block diff --git a/frontend/__tests__/e2e/data/mockHomeData.ts b/frontend/__tests__/e2e/data/mockHomeData.ts index c1792e23af..73182d46fb 100644 --- a/frontend/__tests__/e2e/data/mockHomeData.ts +++ b/frontend/__tests__/e2e/data/mockHomeData.ts @@ -165,6 +165,12 @@ export const mockHomeData = { __typename: 'ReleaseNode', }, ], + sponsors: [ + { + name: 'Sponsor 1', + imageUrl: 'https://avatars.githubusercontent.com/u/1?v=4', + }, + ], statsOverview: { activeChaptersStats: 100, activeProjectsStats: 100, diff --git a/frontend/__tests__/e2e/pages/ProjectDetails.spec.ts b/frontend/__tests__/e2e/pages/ProjectDetails.spec.ts index 3a1d2b07cd..eb8f04c650 100644 --- a/frontend/__tests__/e2e/pages/ProjectDetails.spec.ts +++ b/frontend/__tests__/e2e/pages/ProjectDetails.spec.ts @@ -9,7 +9,7 @@ test.describe('Project Details Page', () => { json: { data: mockProjectDetailsData }, }) }) - await page.goto('/projects/test-project') + await page.goto('/projects/test-project', { timeout: 60000 }) }) test('should have a heading and summary', async ({ page }) => { diff --git a/frontend/__tests__/unit/data/mockHomeData.ts b/frontend/__tests__/unit/data/mockHomeData.ts index 0c5ab8c3af..c3011c4fcf 100644 --- a/frontend/__tests__/unit/data/mockHomeData.ts +++ b/frontend/__tests__/unit/data/mockHomeData.ts @@ -9,6 +9,12 @@ export const mockGraphQLData = { repositoriesCount: 1, }, ], + sponsors: [ + { + name: 'OWASP Foundation', + imageUrl: 'https://example.com/owasp-foundation.png', + }, + ], recentChapters: [ { name: 'OWASP Sivagangai', diff --git a/frontend/src/api/queries/homeQueries.ts b/frontend/src/api/queries/homeQueries.ts index 958858a1d0..74bc5f9fda 100644 --- a/frontend/src/api/queries/homeQueries.ts +++ b/frontend/src/api/queries/homeQueries.ts @@ -51,6 +51,11 @@ export const GET_MAIN_PAGE_DATA = gql` publishedAt tagName } + sponsors { + imageUrl + name + url + } statsOverview { activeChaptersStats activeProjectsStats diff --git a/frontend/src/components/LoadingSpinner.tsx b/frontend/src/components/LoadingSpinner.tsx index 0c0cddb2a1..6c49724cf1 100644 --- a/frontend/src/components/LoadingSpinner.tsx +++ b/frontend/src/components/LoadingSpinner.tsx @@ -8,7 +8,7 @@ const LoadingSpinner: React.FC = ({ imageUrl }) => { const dark = imageUrl.replace('white', 'black') return (
(null) + + useEffect(() => { + if (scrollerRef.current) { + const scrollContainer = scrollerRef.current + + scrollContainer.innerHTML += scrollContainer.innerHTML + } + }, [sponsors]) + + return ( +
+
+ {sponsors.map((sponsor, index) => ( + + ))} +
+
+ ) +} diff --git a/frontend/src/components/MultiSearch.tsx b/frontend/src/components/MultiSearch.tsx index f5969f1183..248883a7ae 100644 --- a/frontend/src/components/MultiSearch.tsx +++ b/frontend/src/components/MultiSearch.tsx @@ -205,7 +205,7 @@ const MultiSearchBar: React.FC = ({ )} ) : ( -
+
)} {showSuggestions && suggestions.length > 0 && (
diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index c049540e46..dae8930282 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -22,6 +22,7 @@ import AnimatedCounter from 'components/AnimatedCounter' import ChapterMap from 'components/ChapterMap' import ItemCardList from 'components/ItemCardList' import LoadingSpinner from 'components/LoadingSpinner' +import MovingLogos from 'components/LogoCarousel' import MultiSearchBar from 'components/MultiSearch' import SecondaryCard from 'components/SecondaryCard' import TopContributors from 'components/ToggleContributors' @@ -216,7 +217,8 @@ export default function Home() { )} />
-
+ +
{counterData.map((stat, index) => (
@@ -242,6 +244,10 @@ export default function Home() { Join OWASP Now + + + +
) diff --git a/frontend/src/types/home.ts b/frontend/src/types/home.ts index f7c374c3e1..bab092acee 100644 --- a/frontend/src/types/home.ts +++ b/frontend/src/types/home.ts @@ -23,6 +23,7 @@ export type MainPageData = { repositoriesCount: number type: string }[] + sponsors: SponsorType[] statsOverview: { activeChaptersStats: number activeProjectsStats: number @@ -30,3 +31,9 @@ export type MainPageData = { countriesStats: number } } + +export type SponsorType = { + imageUrl: string + name: string + url: string +} diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 15a88b8ff9..1a3b50b460 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -53,6 +53,15 @@ export default { sm: 'calc(var(--radius) - 4px)', }, }, + keyframes: { + scroll: { + '0%': { transform: 'translateX(0)' }, + '100%': { transform: 'translateX(-500%)' }, + }, + }, + animation: { + scroll: 'scroll 0.5s linear infinite', + }, }, darkMode: ['class'], plugins: [require('tailwindcss-animate')],