From b1783410315a8e4457f8a49aba97f594f5740a60 Mon Sep 17 00:00:00 2001 From: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> Date: Tue, 7 Apr 2026 18:47:04 +0530 Subject: [PATCH 01/21] sponsor page design --- frontend/src/app/sponsors/apply/layout.tsx | 18 ++ frontend/src/app/sponsors/apply/page.tsx | 15 ++ frontend/src/app/sponsors/layout.tsx | 12 ++ frontend/src/app/sponsors/page.tsx | 93 ++++++++++ frontend/src/components/Header.tsx | 4 +- frontend/src/components/LogoCarousel.tsx | 3 +- .../components/skeletons/SponsorsSkeleton.tsx | 93 ++++++++++ .../components/sponsors/BecomeSponsorCTA.tsx | 49 +++++ .../sponsors/SponsorApplicationForm.tsx | 173 ++++++++++++++++++ .../sponsors/SponsorDiamondCard.tsx | 57 ++++++ .../src/components/sponsors/SponsorHero.tsx | 40 ++++ .../components/sponsors/SponsorTierCard.tsx | 68 +++++++ .../sponsors/SponsorTierSection.tsx | 47 +++++ .../sponsors/SponsorsFaqSection.tsx | 63 +++++++ .../sponsors/SponsorsOpenSourceShowcase.tsx | 98 ++++++++++ frontend/src/server/queries/sponsorQueries.ts | 14 ++ frontend/src/types/__generated__/graphql.ts | 1 + .../__generated__/sponsorQueries.generated.ts | 10 + frontend/src/types/sponsor.ts | 20 ++ frontend/src/utils/constants.ts | 8 +- frontend/src/utils/metadata.ts | 6 + 21 files changed, 887 insertions(+), 5 deletions(-) create mode 100644 frontend/src/app/sponsors/apply/layout.tsx create mode 100644 frontend/src/app/sponsors/apply/page.tsx create mode 100644 frontend/src/app/sponsors/layout.tsx create mode 100644 frontend/src/app/sponsors/page.tsx create mode 100644 frontend/src/components/skeletons/SponsorsSkeleton.tsx create mode 100644 frontend/src/components/sponsors/BecomeSponsorCTA.tsx create mode 100644 frontend/src/components/sponsors/SponsorApplicationForm.tsx create mode 100644 frontend/src/components/sponsors/SponsorDiamondCard.tsx create mode 100644 frontend/src/components/sponsors/SponsorHero.tsx create mode 100644 frontend/src/components/sponsors/SponsorTierCard.tsx create mode 100644 frontend/src/components/sponsors/SponsorTierSection.tsx create mode 100644 frontend/src/components/sponsors/SponsorsFaqSection.tsx create mode 100644 frontend/src/components/sponsors/SponsorsOpenSourceShowcase.tsx create mode 100644 frontend/src/server/queries/sponsorQueries.ts create mode 100644 frontend/src/types/__generated__/sponsorQueries.generated.ts create mode 100644 frontend/src/types/sponsor.ts diff --git a/frontend/src/app/sponsors/apply/layout.tsx b/frontend/src/app/sponsors/apply/layout.tsx new file mode 100644 index 0000000000..8f5cfa4033 --- /dev/null +++ b/frontend/src/app/sponsors/apply/layout.tsx @@ -0,0 +1,18 @@ +import { Metadata } from 'next' +import React from 'react' +import { generateSeoMetadata } from 'utils/metaconfig' + +export const metadata: Metadata = generateSeoMetadata({ + title: 'Become a Sponsor', + description: 'Apply to become an OWASP Nest sponsor and support open source security.', + canonicalPath: '/sponsors/apply', + keywords: ['OWASP sponsor', 'sponsorship', 'open source security', 'support OWASP'], +}) + +export default function SponsorApplyLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return <>{children} +} diff --git a/frontend/src/app/sponsors/apply/page.tsx b/frontend/src/app/sponsors/apply/page.tsx new file mode 100644 index 0000000000..4efc06f5af --- /dev/null +++ b/frontend/src/app/sponsors/apply/page.tsx @@ -0,0 +1,15 @@ +'use client' + +import SponsorApplicationForm from 'components/sponsors/SponsorApplicationForm' + +const SponsorApplyPage = () => { + return ( +
+
+ +
+
+ ) +} + +export default SponsorApplyPage diff --git a/frontend/src/app/sponsors/layout.tsx b/frontend/src/app/sponsors/layout.tsx new file mode 100644 index 0000000000..3be697d4b9 --- /dev/null +++ b/frontend/src/app/sponsors/layout.tsx @@ -0,0 +1,12 @@ +import { Metadata } from 'next' +import React from 'react' +import { getStaticMetadata } from 'utils/metaconfig' +export const metadata: Metadata = getStaticMetadata('sponsors', '/sponsors') + +export default function SponsorsLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return
{children}
+} diff --git a/frontend/src/app/sponsors/page.tsx b/frontend/src/app/sponsors/page.tsx new file mode 100644 index 0000000000..6170203d8c --- /dev/null +++ b/frontend/src/app/sponsors/page.tsx @@ -0,0 +1,93 @@ +'use client' + +import { useQuery } from '@apollo/client/react' +import { useEffect, useMemo } from 'react' +import { ErrorDisplay, handleAppError } from 'app/global-error' +import { GetSponsorsPageDataDocument } from 'types/__generated__/sponsorQueries.generated' +import type { SponsorData, SponsorTier } from 'types/sponsor' +import { TIER_ORDER } from 'types/sponsor' +import SponsorsSkeleton from 'components/skeletons/SponsorsSkeleton' +import BecomeSponsorCTA from 'components/sponsors/BecomeSponsorCTA' +import SponsorHero from 'components/sponsors/SponsorHero' +import SponsorsFaqSection from 'components/sponsors/SponsorsFaqSection' +import SponsorsOpenSourceShowcase from 'components/sponsors/SponsorsOpenSourceShowcase' +import SponsorTierSection from 'components/sponsors/SponsorTierSection' + +const SponsorsPage = () => { + const { data, loading, error } = useQuery(GetSponsorsPageDataDocument) + + useEffect(() => { + if (error) { + handleAppError(error) + } + }, [error]) + + const sponsorsByTier = useMemo(() => { + if (!data?.sponsors) { + return [] as { tier: SponsorTier; sponsors: SponsorData[] }[] + } + + const mapSponsor = (sponsor: (typeof data.sponsors)[number]): SponsorData => ({ + id: sponsor.id, + description: sponsor.description?.trim() || undefined, + imageUrl: sponsor.imageUrl, + name: sponsor.name, + sponsorType: sponsor.sponsorType, + url: sponsor.url, + }) + + const grouped: Record = {} + for (const sponsor of data.sponsors) { + const tier = sponsor.sponsorType || 'Supporter' + if (!grouped[tier]) grouped[tier] = [] + grouped[tier].push(mapSponsor(sponsor)) + } + + return TIER_ORDER.filter((tier) => grouped[tier]?.length > 0).map((tier) => ({ + tier, + sponsors: grouped[tier], + })) + }, [data]) + + if (loading) return + + if (error) { + return ( + + ) + } + + return ( +
+
+ + +
+ + {sponsorsByTier.length > 0 ? ( + sponsorsByTier.map(({ tier, sponsors }) => ( + + )) + ) : ( +
+

+ No sponsors yet. Be the first to support OWASP Nest! +

+
+ )} + + + + + + +
+
+ ) +} + +export default SponsorsPage diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 3b800e73aa..894540823d 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -124,7 +124,7 @@ export default function Header({ isGitHubAuthEnabled }: { readonly isGitHubAuthE
) {

If you're interested in sponsoring the OWASP Nest project ❤️{' '} { + return ( +

+
+
+ + + + +
+ + +
+
+ +
+ {[1, 2].map((tierIdx) => ( +
+
+ + +
+
+ {Array.from({ length: tierIdx === 1 ? 2 : 3 }).map((_, i) => ( +
+ + + {tierIdx === 1 && ( + <> + + + + )} +
+ ))} +
+
+ ))} + +
+ +
+ {[1, 2, 3].map((i) => ( +
+ + + + + +
+ ))} +
+
+ +
+
+
+ + + +
+
+ + + +
+
+
+ +
+ + {[1, 2, 3].map((i) => ( +
+ +
+ ))} +
+
+
+ ) +} + +export default SponsorsSkeleton diff --git a/frontend/src/components/sponsors/BecomeSponsorCTA.tsx b/frontend/src/components/sponsors/BecomeSponsorCTA.tsx new file mode 100644 index 0000000000..daf58282fa --- /dev/null +++ b/frontend/src/components/sponsors/BecomeSponsorCTA.tsx @@ -0,0 +1,49 @@ +import Link from 'next/link' +import { OWASP_NEST_DONATE_URL } from 'utils/constants' + +export default function BecomeSponsorCTA() { + return ( +
+
+
+

+ Corporate sponsorship +

+

+ Your organization can appear here. Applications are reviewed by the OWASP Nest team. +

+ + Apply to sponsor + + → + + +
+ +
+

+ One-time donation +

+

+ Support Nest and OWASP with a single gift through the Foundation — no sponsorship + application required. +

+ + Donate once + + → + + +
+
+
+ ) +} diff --git a/frontend/src/components/sponsors/SponsorApplicationForm.tsx b/frontend/src/components/sponsors/SponsorApplicationForm.tsx new file mode 100644 index 0000000000..9098bb96aa --- /dev/null +++ b/frontend/src/components/sponsors/SponsorApplicationForm.tsx @@ -0,0 +1,173 @@ +'use client' + +import type React from 'react' +import { useCallback, useState } from 'react' +import { FaCheckCircle } from 'react-icons/fa' +import { FormButtons } from 'components/forms/shared/FormButtons' +import { FormContainer } from 'components/forms/shared/FormContainer' +import { FormTextInput } from 'components/forms/shared/FormTextInput' +import { FormTextarea } from 'components/forms/shared/FormTextarea' + +interface FormState { + organizationName: string + website: string + contactEmail: string + message: string +} + +interface TouchedState { + organizationName: boolean + website: boolean + contactEmail: boolean + message: boolean +} + +const initialFormState: FormState = { + organizationName: '', + website: '', + contactEmail: '', + message: '', +} + +const initialTouchedState: TouchedState = { + organizationName: false, + website: false, + contactEmail: false, + message: false, +} + +function validate(form: FormState) { + const errors: Partial> = {} + + if (!form.organizationName.trim()) { + errors.organizationName = 'Organization name is required' + } + + if (!form.contactEmail.trim()) { + errors.contactEmail = 'Contact email is required' + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.contactEmail)) { + errors.contactEmail = 'Enter a valid email address' + } + + if (form.website && !/^https?:\/\/.+\..+/.test(form.website)) { + errors.website = 'Enter a valid URL (e.g., https://example.com)' + } + + return errors +} + +export default function SponsorApplicationForm() { + const [form, setForm] = useState(initialFormState) + const [touched, setTouched] = useState(initialTouchedState) + const [submitted, setSubmitted] = useState(false) + const [loading, setLoading] = useState(false) + + const errors = validate(form) + const hasErrors = Object.keys(errors).length > 0 + + const handleFieldChange = useCallback( + (field: keyof FormState) => (value: string) => { + setForm((prev) => ({ ...prev, [field]: value })) + }, + [] + ) + + const handleBlur = useCallback( + (field: keyof FormState) => () => { + setTouched((prev) => ({ ...prev, [field]: true })) + }, + [] + ) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + setTouched({ + organizationName: true, + website: true, + contactEmail: true, + message: true, + }) + + if (hasErrors) return + + setLoading(true) + await new Promise((resolve) => setTimeout(resolve, 1200)) + setLoading(false) + setSubmitted(true) + } + + if (submitted) { + return ( +
+ +

+ Application received +

+

+ Thank you for your interest in sponsoring OWASP Nest. Our team will review your + application and get in touch. +

+
+ ) + } + + return ( + +

+ Fill out the form below and our team will review your application. We'll follow up + within a few business days. +

+ + + + + + + + setForm((prev) => ({ ...prev, message: e.target.value }))} + error={errors.message} + touched={touched.message} + rows={5} + /> + + +
+ ) +} diff --git a/frontend/src/components/sponsors/SponsorDiamondCard.tsx b/frontend/src/components/sponsors/SponsorDiamondCard.tsx new file mode 100644 index 0000000000..a7464fe1c4 --- /dev/null +++ b/frontend/src/components/sponsors/SponsorDiamondCard.tsx @@ -0,0 +1,57 @@ +import Image from 'next/image' +import Link from 'next/link' +import type { SponsorData } from 'types/sponsor' + +const DIAMOND_MOTTO_FALLBACK = 'Security for everyone, built in the open.' + +interface SponsorDiamondCardProps { + sponsor: SponsorData +} + +/** + * Featured credit row — Blender-style hero tile for the top tier. + */ +export default function SponsorDiamondCard({ sponsor }: SponsorDiamondCardProps) { + const mottoAside = sponsor.motto?.trim() || DIAMOND_MOTTO_FALLBACK + + const logoInner = sponsor.imageUrl ? ( + + ) : ( + Logo + ) + + return ( + +
+
+ {logoInner} +
+
+

+ {sponsor.name} +

+ {sponsor.description?.trim() && ( +

+ {sponsor.description} +

+ )} +

+ “{mottoAside}” +

+
+
+ + ) +} diff --git a/frontend/src/components/sponsors/SponsorHero.tsx b/frontend/src/components/sponsors/SponsorHero.tsx new file mode 100644 index 0000000000..147d09faa3 --- /dev/null +++ b/frontend/src/components/sponsors/SponsorHero.tsx @@ -0,0 +1,40 @@ +import Link from 'next/link' +import { OWASP_NEST_DONATE_URL } from 'utils/constants' + +export default function SponsorHero() { + return ( +
+

+ Community Support +

+

+ Organizations powering OWASP Nest +

+

+ These organizations invest in open, secure software for everyone. +

+

+ Their support keeps OWASP Nest free, open source, and community-driven. +

+
+ + Become a sponsor + + → + + + + One-time donation + +
+
+ ) +} diff --git a/frontend/src/components/sponsors/SponsorTierCard.tsx b/frontend/src/components/sponsors/SponsorTierCard.tsx new file mode 100644 index 0000000000..8bcec8cfba --- /dev/null +++ b/frontend/src/components/sponsors/SponsorTierCard.tsx @@ -0,0 +1,68 @@ +import Image from 'next/image' +import Link from 'next/link' +import type { SponsorData, SponsorTier } from 'types/sponsor' + +import SponsorDiamondCard from 'components/sponsors/SponsorDiamondCard' + +interface SponsorTierCardProps { + sponsor: SponsorData + size: 'lg' | 'md' | 'sm' + tier: SponsorTier +} + +/** Blender credits–style tile: logo-forward, minimal frame, name as caption. */ +const tileBase = + 'group flex flex-col items-center rounded-xl border border-gray-200/70 bg-gray-50/90 text-center outline-none transition hover:border-amber-300/55 hover:bg-white focus-visible:ring-2 focus-visible:ring-amber-400/50 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-50 dark:border-gray-600/50 dark:bg-[#2b2e32] dark:hover:border-amber-500/45 dark:hover:bg-[#32363c] dark:focus-visible:ring-amber-400/60 dark:focus-visible:ring-offset-[#212529]' + +export default function SponsorTierCard({ sponsor, size, tier }: SponsorTierCardProps) { + if (tier === 'Diamond') { + return + } + + const padding = + size === 'lg' ? 'px-5 py-8 sm:py-10' : size === 'md' ? 'px-4 py-7 sm:py-8' : 'px-3 py-5 sm:py-6' + + const logoArea = + size === 'lg' + ? 'min-h-[5.75rem] sm:min-h-[6.75rem]' + : size === 'md' + ? 'min-h-[4.75rem] sm:min-h-[5.25rem]' + : 'min-h-[3.75rem] sm:min-h-16' + + const nameClass = + size === 'lg' + ? 'text-base font-semibold sm:text-lg' + : size === 'md' + ? 'text-sm font-semibold sm:text-base' + : 'text-xs font-semibold sm:text-sm' + + const imgW = size === 'lg' ? 200 : size === 'md' ? 160 : 120 + const imgH = size === 'lg' ? 80 : size === 'md' ? 64 : 48 + + return ( + +
+ {sponsor.imageUrl ? ( + + ) : ( + Logo + )} +
+

+ {sponsor.name} +

+ + ) +} diff --git a/frontend/src/components/sponsors/SponsorTierSection.tsx b/frontend/src/components/sponsors/SponsorTierSection.tsx new file mode 100644 index 0000000000..37a84cec62 --- /dev/null +++ b/frontend/src/components/sponsors/SponsorTierSection.tsx @@ -0,0 +1,47 @@ +import type { SponsorData, SponsorTier } from 'types/sponsor' + +import SponsorTierCard from './SponsorTierCard' + +interface SponsorTierSectionProps { + tier: SponsorTier + sponsors: SponsorData[] +} + +const TIER_GRID: Record = { + Diamond: { cols: 'grid-cols-1', size: 'lg' }, + Platinum: { cols: 'grid-cols-1 sm:grid-cols-2', size: 'lg' }, + Gold: { cols: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3', size: 'md' }, + Silver: { cols: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4', size: 'sm' }, + Supporter: { cols: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4', size: 'sm' }, +} + +const tierHeadingId = (tier: SponsorTier) => `sponsors-tier-${tier.toLowerCase()}` + +export default function SponsorTierSection({ tier, sponsors }: SponsorTierSectionProps) { + if (sponsors.length === 0) return null + + const sortedSponsors = [...sponsors].sort((a, b) => a.name.localeCompare(b.name)) + const { cols, size } = TIER_GRID[tier] + + return ( +
+
+

+ {tier} +

+

+ {tier} sponsors +

+
+ +
+ {sortedSponsors.map((sponsor) => ( + + ))} +
+
+ ) +} diff --git a/frontend/src/components/sponsors/SponsorsFaqSection.tsx b/frontend/src/components/sponsors/SponsorsFaqSection.tsx new file mode 100644 index 0000000000..542f320c78 --- /dev/null +++ b/frontend/src/components/sponsors/SponsorsFaqSection.tsx @@ -0,0 +1,63 @@ +import { FaChevronDown } from 'react-icons/fa6' +import { OWASP_NEST_DONATE_URL } from 'utils/constants' + +const FAQ = [ + { + q: 'How do I become a corporate sponsor?', + a: 'Use the "Apply to sponsor" form on this page. The OWASP Nest team reviews applications and will follow up with next steps, including recognition tier and logo placement.', + }, + { + q: 'How does sponsorship support Nest?', + a: 'Sponsorship helps sustain infrastructure, security reviews, contributor programs, and ongoing development of the platform that connects people to OWASP projects and chapters.', + }, + { + q: 'Can I support Nest without a full sponsorship package?', + a: ( + <> + Yes. One-time and recurring gifts to the OWASP Foundation can be directed to the Nest project via the{' '} + + official donate link + + . Tax treatment depends on your jurisdiction and OWASP Foundation policies—consult a tax professional if needed. + + ), + }, +] as const + +export default function SponsorsFaqSection() { + return ( +
+

+ Frequently asked questions +

+ +
+ {FAQ.map(({ q, a }) => ( +
+ + {q} + + +
+ {a} +
+
+ ))} +
+
+ ) +} diff --git a/frontend/src/components/sponsors/SponsorsOpenSourceShowcase.tsx b/frontend/src/components/sponsors/SponsorsOpenSourceShowcase.tsx new file mode 100644 index 0000000000..3c8a13fc38 --- /dev/null +++ b/frontend/src/components/sponsors/SponsorsOpenSourceShowcase.tsx @@ -0,0 +1,98 @@ +import Link from 'next/link' +import { FaRegHeart } from 'react-icons/fa' +import { FaFlask, FaShieldHalved } from 'react-icons/fa6' + +const SHOWCASE = [ + { + badge: 'Nest', + title: 'A community hub for OWASP projects, chapters, and contributors', + description: + 'Search and filter initiatives, follow contribution opportunities, and stay close to chapters and Slack, all in one place.', + href: '/', + cta: 'Explore Nest', + Icon: FaRegHeart, + }, + { + badge: 'ZAP', + title: "The world's most widely used web application security scanner", + description: + 'Free and open source tooling maintained by a global community, helping teams find issues early in development.', + href: 'https://owasp.org/www-project-zap/', + cta: 'View OWASP ZAP', + external: true, + Icon: FaShieldHalved, + }, + { + badge: 'Juice Shop', + title: 'Hands-on training with a modern, intentionally vulnerable web app', + description: + 'Practice offensive security safely in a realistic stack—used in workshops, capture-the-flag events, and self-paced learning.', + href: 'https://owasp.org/www-project-juice-shop/', + cta: 'View Juice Shop', + external: true, + Icon: FaFlask, + }, +] as const + +export default function SponsorsOpenSourceShowcase() { + return ( +
+

+ The web depends on open source—OWASP helps secure it everywhere +

+ +
+ {SHOWCASE.map(({ badge, title, description, href, cta, external, Icon }) => { + const inner = ( + <> +
+ + + + + {badge} + +
+

{title}

+

+ {description} +

+ + {cta} + + › + + + + ) + + const cardClass = + 'flex h-full flex-col rounded-2xl bg-gray-50 p-6 transition-colors hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 sm:p-7' + + if (external) { + return ( + + {inner} + + ) + } + + return ( + + {inner} + + ) + })} +
+
+ ) +} diff --git a/frontend/src/server/queries/sponsorQueries.ts b/frontend/src/server/queries/sponsorQueries.ts new file mode 100644 index 0000000000..26dcb77f1c --- /dev/null +++ b/frontend/src/server/queries/sponsorQueries.ts @@ -0,0 +1,14 @@ +import { gql } from '@apollo/client' + +export const GET_SPONSORS_PAGE_DATA = gql` + query GetSponsorsPageData { + sponsors { + id + description + imageUrl + name + sponsorType + url + } + } +` diff --git a/frontend/src/types/__generated__/graphql.ts b/frontend/src/types/__generated__/graphql.ts index 0e0cb4ff2a..dcc0224a6e 100644 --- a/frontend/src/types/__generated__/graphql.ts +++ b/frontend/src/types/__generated__/graphql.ts @@ -1054,6 +1054,7 @@ export type SnapshotNode = Node & { export type SponsorNode = Node & { __typename?: 'SponsorNode'; + description: Scalars['String']['output']; /** The Globally Unique ID of this object */ id: Scalars['ID']['output']; imageUrl: Scalars['String']['output']; diff --git a/frontend/src/types/__generated__/sponsorQueries.generated.ts b/frontend/src/types/__generated__/sponsorQueries.generated.ts new file mode 100644 index 0000000000..4db373b308 --- /dev/null +++ b/frontend/src/types/__generated__/sponsorQueries.generated.ts @@ -0,0 +1,10 @@ +import * as Types from './graphql'; + +import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; +export type GetSponsorsPageDataQueryVariables = Types.Exact<{ [key: string]: never; }>; + + +export type GetSponsorsPageDataQuery = { sponsors: Array<{ __typename: 'SponsorNode', id: string, description: string, imageUrl: string, name: string, sponsorType: string, url: string }> }; + + +export const GetSponsorsPageDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSponsorsPageData"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sponsors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"imageUrl"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"sponsorType"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode; diff --git a/frontend/src/types/sponsor.ts b/frontend/src/types/sponsor.ts new file mode 100644 index 0000000000..c807188ac4 --- /dev/null +++ b/frontend/src/types/sponsor.ts @@ -0,0 +1,20 @@ +export type SponsorTier = 'Diamond' | 'Platinum' | 'Gold' | 'Silver' | 'Supporter' + +export type SponsorData = { + id: string + imageUrl: string + name: string + sponsorType: string + url: string + /** Shown on large-tier cards when API provides it (GraphQL may add later). */ + description?: string + /** Diamond tier: short line shown in the editorial aside (GraphQL may add later). */ + motto?: string +} + +export type SponsorsByTier = { + tier: SponsorTier + sponsors: SponsorData[] +} + +export const TIER_ORDER: SponsorTier[] = ['Diamond', 'Platinum', 'Gold', 'Silver', 'Supporter'] diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts index 01b90cad6d..6d923f3a78 100644 --- a/frontend/src/utils/constants.ts +++ b/frontend/src/utils/constants.ts @@ -4,6 +4,10 @@ import { FaBluesky } from 'react-icons/fa6' import type { Link } from 'types/link' import type { Section } from 'types/section' +/** OWASP Nest project donation (one-time or recurring via OWASP). */ +export const OWASP_NEST_DONATE_URL = + 'https://owasp.org/donate/?reponame=www-project-nest&title=OWASP+Nest' + export const headerLinks: Link[] = [ { text: 'Community', @@ -61,8 +65,8 @@ export const footerSections: Section[] = [ href: 'https://owasp.org/www-community/initiatives/gsoc/gsoc2026ideas#owasp-nest', }, { - text: 'Sponsor', - href: 'https://owasp.org/donate/?reponame=www-project-nest&title=OWASP+Nest', + text: 'Sponsors', + href: '/sponsors', }, ], }, diff --git a/frontend/src/utils/metadata.ts b/frontend/src/utils/metadata.ts index 503c39d48e..d33fd26e00 100644 --- a/frontend/src/utils/metadata.ts +++ b/frontend/src/utils/metadata.ts @@ -60,4 +60,10 @@ export const METADATA_CONFIG = { pageTitle: 'Organizations', type: 'website', }, + sponsors: { + description: 'Meet the organizations sponsoring OWASP Nest and supporting open source security.', + keywords: ['OWASP sponsors', 'sponsorship', 'open source security', 'corporate supporters'], + pageTitle: 'Sponsors', + type: 'website', + }, } From eb34099476bb638fc0ead263edacc253bd6a365d Mon Sep 17 00:00:00 2001 From: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> Date: Wed, 8 Apr 2026 21:18:59 +0530 Subject: [PATCH 02/21] frontend fixes + expand backend --- backend/apps/api/rest/v0/sponsor.py | 74 +++++- backend/apps/owasp/admin/sponsor.py | 30 ++- .../owasp/api/internal/queries/sponsor.py | 2 +- ...or_status_contact_email_chapter_project.py | 62 +++++ backend/apps/owasp/models/sponsor.py | 37 ++- .../forms/shared/FormTextarea.test.tsx | 7 +- frontend/src/app/sponsors/apply/page.tsx | 9 +- frontend/src/app/sponsors/page.tsx | 1 - frontend/src/components/ModuleForm.tsx | 6 + frontend/src/components/ProgramForm.tsx | 6 + .../components/forms/shared/FormTextInput.tsx | 18 +- .../components/forms/shared/FormTextarea.tsx | 13 +- .../components/skeletons/SponsorsSkeleton.tsx | 4 +- .../sponsors/SponsorApplicationForm.tsx | 227 ++++++++++++------ .../components/sponsors/SponsorApplyHero.tsx | 22 ++ .../sponsors/SponsorDiamondCard.tsx | 9 +- .../src/components/sponsors/SponsorHero.tsx | 2 +- .../components/sponsors/SponsorTierCard.tsx | 3 +- frontend/src/server/queries/sponsorQueries.ts | 1 - frontend/src/types/__generated__/graphql.ts | 1 - .../__generated__/sponsorQueries.generated.ts | 4 +- frontend/src/types/sponsor.ts | 4 +- frontend/src/utils/constants.ts | 3 + 23 files changed, 431 insertions(+), 114 deletions(-) create mode 100644 backend/apps/owasp/migrations/0073_sponsor_status_contact_email_chapter_project.py create mode 100644 frontend/src/components/sponsors/SponsorApplyHero.tsx diff --git a/backend/apps/api/rest/v0/sponsor.py b/backend/apps/api/rest/v0/sponsor.py index 4641e7639c..a4e814dcf5 100644 --- a/backend/apps/api/rest/v0/sponsor.py +++ b/backend/apps/api/rest/v0/sponsor.py @@ -11,6 +11,7 @@ from apps.api.decorators.cache import cache_response from apps.api.rest.v0.common import ValidationErrorSchema +from apps.common.utils import slugify from apps.owasp.models.sponsor import Sponsor as SponsorModel router = RouterPaginated(tags=["Sponsors"]) @@ -64,9 +65,25 @@ class SponsorFilter(FilterSchema): ) +class SponsorApplyRequest(Schema): + """Request schema for sponsor application.""" + + organization_name: str = Field(..., min_length=1, description="Organization name") + website: str = Field("", description="Organization website URL") + contact_email: str = Field(..., min_length=1, description="Contact email address") + message: str = Field("", description="Sponsorship interest / message") + + +class SponsorApplyResponse(Schema): + """Response schema for a successful sponsor application.""" + + key: str + message: str + + @router.get( "/", - description="Retrieve a paginated list of OWASP sponsors.", + description="Retrieve a paginated list of active OWASP sponsors.", operation_id="list_sponsors", response=list[Sponsor], summary="List sponsors", @@ -80,13 +97,16 @@ def list_sponsors( description="Ordering field", ), ) -> list[Sponsor]: - """Get sponsors.""" - return filters.filter(SponsorModel.objects.order_by(ordering or "name")) + """Get active sponsors.""" + qs = SponsorModel.objects.filter(status=SponsorModel.Status.ACTIVE).order_by( + ordering or "name" + ) + return filters.filter(qs) @router.get( "/{str:sponsor_id}", - description="Retrieve a sponsor details.", + description="Retrieve sponsor details.", operation_id="get_sponsor", response={ HTTPStatus.BAD_REQUEST: ValidationErrorSchema, @@ -100,8 +120,50 @@ def get_sponsor( request: HttpRequest, sponsor_id: str = Path(..., example="adobe"), ) -> SponsorDetail | SponsorError: - """Get sponsor.""" - if sponsor := SponsorModel.objects.filter(key__iexact=sponsor_id).first(): + """Get a single active sponsor.""" + sponsor = SponsorModel.objects.filter( + key__iexact=sponsor_id, + status=SponsorModel.Status.ACTIVE, + ).first() + if sponsor: return sponsor return Response({"message": "Sponsor not found"}, status=HTTPStatus.NOT_FOUND) + + +@router.post( + "/apply", + description="Submit a sponsor application. Creates a draft record for admin review.", + operation_id="apply_sponsor", + response={ + HTTPStatus.CREATED: SponsorApplyResponse, + HTTPStatus.BAD_REQUEST: SponsorError, + }, + summary="Apply to become a sponsor", +) +def apply_sponsor( + request: HttpRequest, + payload: SponsorApplyRequest, +) -> tuple[int, SponsorApplyResponse | SponsorError]: + """Create a draft sponsor application.""" + key = slugify(payload.organization_name) + + if SponsorModel.objects.filter(key=key).exists(): + return HTTPStatus.BAD_REQUEST, SponsorError( + message=f"An application for '{payload.organization_name}' already exists." + ) + + SponsorModel.objects.create( + key=key, + name=payload.organization_name, + sort_name=payload.organization_name, + contact_email=payload.contact_email, + url=payload.website, + description=payload.message, + status=SponsorModel.Status.DRAFT, + ) + + return HTTPStatus.CREATED, SponsorApplyResponse( + key=key, + message="Application received. The Nest team will review and follow up.", + ) diff --git a/backend/apps/owasp/admin/sponsor.py b/backend/apps/owasp/admin/sponsor.py index c124b7cd67..19f5d28e09 100644 --- a/backend/apps/owasp/admin/sponsor.py +++ b/backend/apps/owasp/admin/sponsor.py @@ -10,10 +10,12 @@ class SponsorAdmin(admin.ModelAdmin, StandardOwaspAdminMixin): """Admin for Sponsor model.""" + actions = ("activate_sponsors", "archive_sponsors") list_display = ( "name", - "sort_name", + "status", "sponsor_type", + "contact_email", "is_member", "member_type", ) @@ -21,8 +23,10 @@ class SponsorAdmin(admin.ModelAdmin, StandardOwaspAdminMixin): "name", "sort_name", "description", + "contact_email", ) list_filter = ( + "status", "sponsor_type", "is_member", "member_type", @@ -35,6 +39,7 @@ class SponsorAdmin(admin.ModelAdmin, StandardOwaspAdminMixin): "name", "sort_name", "description", + "contact_email", ) }, ), @@ -52,13 +57,36 @@ class SponsorAdmin(admin.ModelAdmin, StandardOwaspAdminMixin): "Status", { "fields": ( + "status", "is_member", "member_type", "sponsor_type", ) }, ), + ( + "Entity Associations", + { + "fields": ( + "chapter", + "project", + ), + "classes": ("collapse",), + }, + ), ) + @admin.action(description="Activate selected sponsors") + def activate_sponsors(self, request, queryset) -> None: + """Set selected sponsors to active status.""" + updated = queryset.update(status=Sponsor.Status.ACTIVE) + self.message_user(request, f"{updated} sponsor(s) marked as active.") + + @admin.action(description="Archive selected sponsors") + def archive_sponsors(self, request, queryset) -> None: + """Set selected sponsors to archived status.""" + updated = queryset.update(status=Sponsor.Status.ARCHIVED) + self.message_user(request, f"{updated} sponsor(s) archived.") + admin.site.register(Sponsor, SponsorAdmin) diff --git a/backend/apps/owasp/api/internal/queries/sponsor.py b/backend/apps/owasp/api/internal/queries/sponsor.py index 80014e529f..19374b90a8 100644 --- a/backend/apps/owasp/api/internal/queries/sponsor.py +++ b/backend/apps/owasp/api/internal/queries/sponsor.py @@ -15,7 +15,7 @@ class SponsorQuery: def sponsors(self) -> list[SponsorNode]: """Resolve sponsors.""" return sorted( - Sponsor.objects.all(), + Sponsor.objects.filter(status=Sponsor.Status.ACTIVE), key=lambda x: { Sponsor.SponsorType.DIAMOND: 1, Sponsor.SponsorType.PLATINUM: 2, diff --git a/backend/apps/owasp/migrations/0073_sponsor_status_contact_email_chapter_project.py b/backend/apps/owasp/migrations/0073_sponsor_status_contact_email_chapter_project.py new file mode 100644 index 0000000000..76aa8e1f29 --- /dev/null +++ b/backend/apps/owasp/migrations/0073_sponsor_status_contact_email_chapter_project.py @@ -0,0 +1,62 @@ +# Generated by Django 5.2 on 2026-04-07 00:00 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("owasp", "0072_project_project_name_gin_idx_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="sponsor", + name="status", + field=models.CharField( + choices=[ + ("draft", "Draft"), + ("active", "Active"), + ("archived", "Archived"), + ], + db_index=True, + default="active", + max_length=10, + verbose_name="Status", + ), + ), + migrations.AddField( + model_name="sponsor", + name="contact_email", + field=models.EmailField(blank=True, max_length=254, verbose_name="Contact Email"), + ), + migrations.AlterField( + model_name="sponsor", + name="sort_name", + field=models.CharField(blank=True, max_length=255, verbose_name="Sort Name"), + ), + migrations.AddField( + model_name="sponsor", + name="chapter", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="sponsors", + to="owasp.chapter", + verbose_name="Chapter", + ), + ), + migrations.AddField( + model_name="sponsor", + name="project", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="sponsors", + to="owasp.project", + verbose_name="Project", + ), + ), + ] diff --git a/backend/apps/owasp/models/sponsor.py b/backend/apps/owasp/models/sponsor.py index d7d0e38158..285ca72191 100644 --- a/backend/apps/owasp/models/sponsor.py +++ b/backend/apps/owasp/models/sponsor.py @@ -37,11 +37,21 @@ class MemberType(models.TextChoices): GOLD = "Gold" SILVER = "Silver" + class Status(models.TextChoices): + """Sponsor application status.""" + + DRAFT = "draft", "Draft" + ACTIVE = "active", "Active" + ARCHIVED = "archived", "Archived" + # 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) + sort_name = models.CharField(verbose_name="Sort Name", max_length=255, blank=True) + + # Contact + contact_email = models.EmailField(verbose_name="Contact Email", blank=True) # URLs and images url = models.URLField(verbose_name="Website URL", blank=True) @@ -49,6 +59,13 @@ class MemberType(models.TextChoices): image_url = models.CharField(verbose_name="Image Path", max_length=255, blank=True) # Status fields + status = models.CharField( + verbose_name="Status", + max_length=10, + choices=Status.choices, + default=Status.ACTIVE, + db_index=True, + ) is_member = models.BooleanField(verbose_name="Is Corporate Sponsor", default=False) member_type = models.CharField( verbose_name="Member Type", @@ -64,6 +81,24 @@ class MemberType(models.TextChoices): default=SponsorType.NOT_SPONSOR, ) + # Optional entity associations + chapter = models.ForeignKey( + "owasp.Chapter", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="sponsors", + verbose_name="Chapter", + ) + project = models.ForeignKey( + "owasp.Project", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="sponsors", + verbose_name="Project", + ) + def __str__(self) -> str: """Sponsor human readable representation.""" return f"{self.name}" diff --git a/frontend/__tests__/unit/components/forms/shared/FormTextarea.test.tsx b/frontend/__tests__/unit/components/forms/shared/FormTextarea.test.tsx index e4257ad797..336c713427 100644 --- a/frontend/__tests__/unit/components/forms/shared/FormTextarea.test.tsx +++ b/frontend/__tests__/unit/components/forms/shared/FormTextarea.test.tsx @@ -4,6 +4,7 @@ import { FormTextarea } from 'components/forms/shared/FormTextarea' describe('FormTextarea', () => { const defaultProps = { id: 'test-textarea', + name: 'test-textarea', label: 'Test Label', placeholder: 'Enter text', value: '', @@ -37,15 +38,15 @@ describe('FormTextarea', () => { render() expect(screen.getByText(errorMsg)).toBeInTheDocument() const textarea = screen.getByRole('textbox') - expect(textarea).toHaveClass('border-red-500') - expect(textarea).toHaveClass('dark:border-red-500') + expect(textarea.className).toMatch(/border-red-500/) + expect(textarea.className).toMatch(/dark:border-red-/) }) it('does not render error message when not touched', () => { render() expect(screen.queryByText('Error')).not.toBeInTheDocument() const textarea = screen.getByRole('textbox') - expect(textarea).toHaveClass('border-gray-300') + expect(textarea.className).toMatch(/border-gray-200/) }) it('calls onChange handler when typed into', () => { diff --git a/frontend/src/app/sponsors/apply/page.tsx b/frontend/src/app/sponsors/apply/page.tsx index 4efc06f5af..fb37e8d15d 100644 --- a/frontend/src/app/sponsors/apply/page.tsx +++ b/frontend/src/app/sponsors/apply/page.tsx @@ -1,12 +1,17 @@ 'use client' +import SponsorApplyHero from 'components/sponsors/SponsorApplyHero' import SponsorApplicationForm from 'components/sponsors/SponsorApplicationForm' const SponsorApplyPage = () => { return (
-
- +
+ + +
+ +
) diff --git a/frontend/src/app/sponsors/page.tsx b/frontend/src/app/sponsors/page.tsx index 6170203d8c..2f85c5f6df 100644 --- a/frontend/src/app/sponsors/page.tsx +++ b/frontend/src/app/sponsors/page.tsx @@ -29,7 +29,6 @@ const SponsorsPage = () => { const mapSponsor = (sponsor: (typeof data.sponsors)[number]): SponsorData => ({ id: sponsor.id, - description: sponsor.description?.trim() || undefined, imageUrl: sponsor.imageUrl, name: sponsor.name, sponsorType: sponsor.sponsorType, diff --git a/frontend/src/components/ModuleForm.tsx b/frontend/src/components/ModuleForm.tsx index 31b11a42a5..c4f1b618b8 100644 --- a/frontend/src/components/ModuleForm.tsx +++ b/frontend/src/components/ModuleForm.tsx @@ -179,6 +179,7 @@ const ModuleForm = ({
void + autoComplete?: string } export const FormTextInput = ({ id, + name, type = 'text', label, placeholder, @@ -39,11 +46,13 @@ export const FormTextInput = ({ min, className, onBlur, + autoComplete, }: FormTextInputProps) => { return (
diff --git a/frontend/src/components/forms/shared/FormTextarea.tsx b/frontend/src/components/forms/shared/FormTextarea.tsx index 77ca1ed406..dc122298ae 100644 --- a/frontend/src/components/forms/shared/FormTextarea.tsx +++ b/frontend/src/components/forms/shared/FormTextarea.tsx @@ -4,6 +4,7 @@ import type React from 'react' interface FormTextareaProps { id: string + name: string label: string placeholder: string value: string @@ -16,6 +17,7 @@ interface FormTextareaProps { export const FormTextarea = ({ id, + name, label, placeholder, value, @@ -31,20 +33,23 @@ export const FormTextarea = ({