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)
- A · Hero section
- B · Radar summary / signal board
- C · Status tabs (All / Open / Closing soon / Rolling / Closed)
- D · Search + filters
- E · Opportunity cards grid (the main listing)
- F · Weekly Opportunity Signals subscription block
- G · Continue your founder journey (cross-module cards)
- 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.
