Skip to main content

Developer Page Specifications

Listing Page

/:lang/opportunity-radar — page sections, fields, sources, states.

Complete

Route: /:lang/opportunity-radar. Displays every curated opportunity signal with filtering, sorting, status tabs, search, and a subscription block. Document every section, every field, every data source, and every state — implementers should not need to read another doc.

Page sections (top → bottom)

  1. A · Hero section
  2. B · Radar summary / signal board
  3. C · Status tabs (All / Open / Closing soon / Rolling / Closed)
  4. D · Search + filters
  5. E · Opportunity cards grid (the main listing)
  6. F · Weekly Opportunity Signals subscription block
  7. G · Continue your founder journey (cross-module cards)
  8. H · Footer (global component, never per-page)

A · Hero section — fields

Eyebrow label
Static string. Source: CMS page config (PageConfig.opportunityRadarListing.eyebrow). Example: "Opportunity Intelligence for Founders". Required: yes.
Page title (H1)
Source: PageConfig.opportunityRadarListing.title. Example: "Opportunity Radar". Required: yes. Must be the only <h1> on the page.
Page description
Source: PageConfig.opportunityRadarListing.description. Renders as the lede paragraph below H1. Required: yes.
Short supporting note
Source: PageConfig.opportunityRadarListing.supportingNote. Optional secondary line under the description. Required: no.
Primary CTA (label + action)
Label source: PageConfig.opportunityRadarListing.primaryCtaLabel (default: "Explore Opportunities"). Action: smooth-scroll to #opportunities-listing. Required: yes.
Secondary CTA (label + action)
Label: "Get Weekly Digest". Action: smooth-scroll to #subscribe-block. Required: yes.
Feature bullets[]
Source: PageConfig.opportunityRadarListing.featureBullets (array of { icon, label }). Examples: Editorially curated · MENA-wide coverage · Free for founders · Verified by our editorial team. Required: yes (≥3).

B · Radar summary / signal board

Total open count
Query: SELECT COUNT(*) FROM Opportunity WHERE Status = 'Open' AND IsActive = true. Cached server-side, refreshes on every publish/unpublish.
Closing soon count
Query: WHERE Status = 'ClosingSoon' OR (DeadlineDate BETWEEN NOW() AND NOW() + INTERVAL '7 days'). 7-day threshold configurable via FeatureFlag.opportunityClosingSoonDays.
Rolling count
Query: WHERE IsRolling = true AND IsActive = true.
Tracked count
Query: WHERE IsActive = true. Total active opportunities across all statuses.
Latest signals list (last 5)
Query: ORDER BY GREATEST(PublishedAt, LastVerifiedAt) DESC LIMIT 5. Each item: { id, slug, titleLang, typeLabel, countryCode, deadlineLabel, statusBadge, detailsUrl }.

C · Status tabs — fields + behavior

  • All signals count (sum of Open + ClosingSoon + Rolling + Closed, only IsActive = true).
  • Open count — see B above.
  • Closing soon count — see B above.
  • Rolling count — see B above.
  • Closed count — Query: WHERE Status = 'Closed' OR DeadlineDate < NOW(). Closed entries stay queryable but excluded from the default sort.

Behavior: clicking a tab applies a filter to the cards grid (E). Active tab uses the design-system primary tint + bold weight. Counts update reactively as filters/search refine the dataset. Empty state — see UI states section below.

D · Search + filters

Search input (q)
Debounced 300ms. Searches across: OpportunityLang.Title, OpportunityLang.ShortDescription, Organizer.Name, Country.Name, OpportunityType.Name, OpportunitySector.Name. Server-side full-text via PostgreSQL tsvector or equivalent. Min length: 2 chars.
Filter — type
Multi-select. Source: OpportunityType table. Values: accelerator, incubator, grant, competition, fellowship, soft-landing, residency, vc-program. Query param: type=accelerator,grant.
Filter — country
Multi-select. Source: Country table (16 MENA codes from §9.6). Query param: country=eg,sa.
Filter — startup stage
Multi-select. Values: idea, pre-seed, seed, series-a, growth, sme, scaleup. Mirrors §9.6 StartupStage type. Query param: stage=pre-seed,seed.
Filter — sector
Multi-select. Source: OpportunitySector table. Free taxonomy populated editorially: fintech, healthtech, edtech, climatetech, deeptech, agritech, etc. Query param: sector=fintech,climatetech.
Filter — status
Single-select (mirrors status tabs C). Query param: status=open|closing-soon|rolling|closed|all.
Sort dropdown
Values: most-relevant (default when q present, else deadline-soonest) · deadline-soonest · newest · recently-verified · closing-soon. Query param: sort=deadline-soonest.

E · Opportunity cards grid — per-card fields

id
Source: Opportunity.Id (UUID). Internal only, never displayed.
slug
Source: Opportunity.Slug. Kebab-case, ASCII, unique. Used in card link → /:lang/opportunity-radar/{slug}.
title
Source: OpportunityLang.Title where Lang = current locale. Falls back to English if AR variant missing. Required: yes.
type label
Source: OpportunityType.NameLang. Display as small uppercase chip in card meta-row. Required: yes.
status badge
Source: Opportunity.Status. Tint by value (see Status Logic page). Required: yes.
sponsored badge
Source: Opportunity.IsSponsored. Show only when true. Distinct visual treatment (orange tint, "SPONSORED" label).
verified badge
Source: Opportunity.IsVerified. Show only when true. Small checkmark icon + "Verified" tooltip.
deadline label
Computed from Opportunity.DeadlineDate AND Opportunity.IsRolling. Logic: if IsRolling → "Rolling applications"; else if days_to(DeadlineDate) ≤ 1 → "Last day to apply"; else if days_to ≤ 7 → "{N} days left"; else → formatted date string in current locale. Required: yes.
closing-soon label
Conditional. Shown when computed status === ClosingSoon. Overlays a warning-tinted "Closing soon" pill at the top-right of the card.
short description
Source: OpportunityLang.ShortDescription. Max 200 chars, 3-line clamp via CSS. Required: yes.
country
Source: Country.Code (ISO 3166-1 alpha-2) + Country.NameLang. Display: flag emoji + name. Required: yes.
stage[]
Source: OpportunityStage join table. Display: up to 2 chips; if more, "+N" overflow chip. Required: optional.
sector[]
Source: OpportunitySector join table. Same display rules as stage. Required: optional.
organizer name
Source: Organizer.Name. Display: small text below title with optional logo (Organizer.LogoUrl). Required: yes.
officialUrl
Source: Opportunity.OfficialUrl. Required when Status ∈ { Open, ClosingSoon, Rolling }. If missing, "Apply via Official Source" CTA renders disabled with tooltip.
CTA #1 — Apply
Label: "Apply via Official Source". Action: window.open(officialUrl, '_blank', 'noopener,noreferrer'). Fires analytics event opportunity_apply_click.
CTA #2 — View Opportunity
Action: routerLink to /:lang/opportunity-radar/{slug}. Fires opportunity_card_click.

Card status tints

  • Open → green accent (color-token: --status-open / --badge-verified-like).
  • ClosingSoon → orange accent (--brand-orange).
  • Rolling → purple accent (--primary tint at 60% opacity).
  • Closed → muted gray (--muted-foreground). Card opacity drops to 0.6.

F · Weekly Opportunity Signals subscription block

Block title + description
Source: PageConfig.opportunityRadarListing.subscribeBlock.{title,description}.
Email input
Required. Validation: RFC-compliant email regex. Submit disabled until valid.
Country preference
Optional. Same options as filter D country list. Defaults to "All MENA".
Stage preference
Optional. Same options as filter D stage list. Defaults to "All stages".
Consent checkbox (consentPlatform)
Required. Text: "I agree to receive weekly opportunity signals from StartupHub.today." GDPR / PDPL gate. Submit disabled until checked.
Partner consent checkbox (consentPartners)
Optional. Text: "Also share my profile with relevant sponsor partners." Routes the lead to sponsor CRM if checked. Default unchecked.
Subscribe button
POST /api/subscriptions/opportunity-digest. Body: { email, country?, stage?, consentPlatform, consentPartners, source: 'listing' }. On success: show inline confirmation. On error: show inline error banner.

G · Continue your founder journey — cross-module cards

  • 4 static cards. Source: PageConfig.opportunityRadarListing.crossModuleCards[].
  • Each card: { title, shortDescription, targetModule, targetRoute, displayOrder, icon }.
  • Canonical targets: Build your Founder Profile (/founders/edit) · Read Founder Files (/founder-files) · Apply to Startup Showcase (/showcase) · Track MENA Events (/calendar).
  • Each card click fires opportunity_cross_module_click with { target_module, target_route }.

UI states

Loading (initial fetch)
Skeleton cards: same layout as real cards, animated shimmer. Show 6 skeletons (matches default page size).
Empty (no results after filter)
Render the documented empty state — illustration + locale-correct message quoting the active filters + "Clear filters" CTA + link to /:lang/opportunity-radar (no filters).
Error (API failure)
Toast notification (top-right LTR, top-left RTL) + inline retry CTA above the cards grid. Cards area keeps any previously-loaded data.
Pagination — infinite scroll
IntersectionObserver on a sentinel below the grid. Each batch = 24 cards. Fire opportunity_listing_load_more on each fetch beyond the first.