# affiliate-dashboard Full Instructions

## Ordio Loop iconography (no emoji in UI)

- Use the shared registry only: [`v2/includes/affiliate-loop-icons.php`](../../v2/includes/affiliate-loop-icons.php) (PHP) and [`v2/js/affiliate-loop-icons.js`](../../v2/js/affiliate-loop-icons.js) → load `affiliate-loop-icons.min.js` before any script that calls `AffiliateLoopIcons.svg()`.
- Badge definitions in [`v2/config/affiliate-badges.php`](../../v2/config/affiliate-badges.php) must use **`icon_key`**, not Unicode emoji. APIs return `icon_key` for clients to map to SVG.
- Admins can review all keys at **`/partner/icon-preview`** (see [`v2/pages/partner-icon-preview.php`](../../v2/pages/partner-icon-preview.php)).
- Full conventions: [`docs/systems/affiliate/ORDIO_LOOP_ICONOGRAPHY.md`](../../docs/systems/affiliate/ORDIO_LOOP_ICONOGRAPHY.md).

## Dashboard Patterns

### Page Structure

**Required Includes:**

```php
<?php
session_start();
require_once __DIR__ . '/../config/affiliate-config.php';
require_once __DIR__ . '/../includes/affiliate-auth.php';

// Require authentication for protected pages
requireAffiliateAuth();

$partner = getAuthenticatedPartner();
$partnerId = $partner['partner_id'];
?>
```

**Head Section (Standalone Architecture):**

The affiliate partner system uses a standalone head section (`affiliate-head.php`) that is completely separate from the main marketing website. This ensures no conflicts with marketing site scripts, styles, or tracking.

**Option 1: Direct Include (Legacy Pattern):**

```php
<title>Page Title - <?php echo AFFILIATE_PROGRAM_NAME; ?></title>
<meta name="robots" content="noindex, nofollow">

<?php
// Optional: Enable Chart.js for dashboard pages with charts
$chartJs = "true"; // or "false" (default)

// Optional: Enable Alpine.js if needed for interactivity
$alpineJs = "false"; // or "true" (default: false)

include '../base/affiliate-head.php';
?>
```

**Option 2: Page Template (Recommended):**

Use `renderAffiliatePage()` wrapper for consistent page structure:

```php
require_once __DIR__ . '/../includes/affiliate-page-template.php';

$pageContent = '<div>Page content HTML</div>';
$pageStyles = '<style>/* Page-specific styles */</style>';
$pageScripts = '<script>/* Page-specific scripts */</script>';

renderAffiliatePage(
    'Page Title',
    'active-nav-item',
    $pageContent,
    [
        'chartJs' => 'true',
        'metaDescription' => 'Page description',
        'customHead' => $pageStyles,
        'customScripts' => $pageScripts
    ]
);
```

**Sidebar Order:**

- Order follows: Dashboard → Leads → Earnings → Referral-URLs → Resources → Levels → Leaderboard (Hauptmenü), then Verwaltung (admin only), Einstellungen.
- Rationale: Overview → Pipeline (Leads → Earnings) → Tools (Referral → Resources) → Gamification (Levels → Leaderboard) → Settings.

**Collapsible sidebar (Product Updates–style):**

- **Markup / IDs:** `affiliate-page-template.php` renders `#affiliateSidebarOverlay`, `#affiliateSidebar`, `#affiliateMain`, and `#affiliateMobileMenuBtn` (mobile-only toolbar). See `affiliate-sidebar.php` for `#affiliateSidebarToggle` and `nav-item-text` labels.
- **Desktop:** Toggle adds `.collapsed` on the sidebar and `.sidebar-collapsed` on main; width transitions via `affiliate-shared.css`. Persist with `localStorage` key **`affiliateSidebarCollapsed`** (not the Product Updates admin key).
- **Mobile (≤768px):** Drawer uses `.mobile-open` + `.affiliate-sidebar-overlay.show`; body gets `affiliate-sidebar-mobile-open` for scroll lock; overlay tap, nav link follow, or **Escape** closes the drawer.
- **Script:** `v2/js/affiliate-sidebar.js` (loaded minified from `affiliate-head.php` after `affiliate-utils`).

**Level Badge in Header:**

- Main pages (Dashboard, Leads, Earnings, Referral-URLs, Resources, Levels, Leaderboard) show a Level badge in the header via `renderPartnerLevelBadge()` from `affiliate-page-template.php`.
- Use `<div class="header-actions">' . renderPartnerLevelBadge() . ' <a href="?logout=1" class="logout-button">Abmelden</a></div>` in the admin-header.

**Performance:**

- **API call inventory:** Dashboard (1), Admin (2 parallel), Leads (1), Earnings (1), Levels (1), Leaderboard (1), Referral-URLs (1), Resources (0), Settings (0). See `docs/systems/affiliate/DASHBOARD_GUIDE.md` for full table.
- **Cache headers:** HubSpot APIs use `Cache-Control: private, max-age=60`; sitemap uses `max-age=300`. Set via `setAffiliateAPICacheHeaders()` or directly.

**UTM Reporting:**

- **Sync:** `getContactsByAffiliatePartner()` fetches UTM properties (`source__c`, `utm_medium__c`, `utm_campaign__c`, `utm_term__c`, `content__c`). See `docs/systems/affiliate/CACHE_ARCHITECTURE.md`.
- **Leads API:** `formatLeadForAPI()` includes `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term` (null when not set).
- **Leads page:** Quelle, Medium, Kampagne columns; Lead-Quellen with three cards (table + horizontal bar chart each) in a 3-column grid when at least one lead has UTM data; hint card when none. Load Chart.js via `chartJs: 'true'`.
- **Dashboard:** UTM badge in "Letzte Aktivitäten" (campaign when set; otherwise source / medium when either exists).

**What's Included in `affiliate-head.php`:**

- Essential meta tags (charset, viewport, robots)
- Favicon links
- Font preloads (Inter, Gilroy) - minimal set
- Font face definitions with `font-display: swap`
- Minimal CSS reset
- **Partner CSS (minified only):** `affiliate-shared.min.css`, `affiliate-auth.min.css` (login, register, reset-password), `affiliate-dashboard.min.css` - loaded via `affiliate-head.php`. Page-specific: `affiliate-levels.min.css` (Levels page, customHead), `affiliate-referral-urls.min.css` (Referral URLs page, customHead), `affiliate-resources.min.css` (Resources page, customHead), `affiliate-admin.min.css` (Verwaltung/Admin page, customHead). After editing any `v2/css/affiliate-*.css`, run `npm run minify`. For full pre-deploy: `make pre-deploy`.
- `affiliate-utils.js` (shared utility functions) - automatically included
- Optional Chart.js (if `$chartJs = "true"`)
- Optional `affiliate-charts.js` (chart factories) - loaded with Chart.js
- Optional Alpine.js (if `$alpineJs = "true"`)

**What's NOT Included (Separated from Marketing Site):**

- NO Google Analytics / HubSpot tracking scripts
- NO AOS animations library
- NO Swiper carousel library
- NO Tailwind CSS
- NO marketing site critical CSS
- NO service worker registration
- NO SEO meta tags (hreflang, canonical)
- NO DNS prefetch for marketing resources
- NO Alpine.js mobile navigation scripts

**Pages Using Standalone Head:**

- All partner dashboard pages (`partner-dashboard.php`, `partner-leads.php`, etc.)
- Partner login/registration pages (`partner-login.php`, `partner-register.php`)
- Partner password reset page (`partner-reset-password.php`)

**Pages Keeping Marketing Integration:**

- `partner-program.php` - Public marketing landing page (uses `base/head.php` + header/footer)

**Partner Auth Pages (Login, Register, Reset):**

- Use `affiliate-auth.css` (minified: `affiliate-auth.min.css`) for shared design tokens and layout. Design tokens from `affiliate-shared.css` (`--ordio-blue`, `--ordio-blue-dark`).
- **Brand consistency:** Use ordio-blue (#4D8EF3) only for buttons, links, focus states. Never use purple/violet (#667eea, #764ba2).
- **Required elements:** "← Zurück zur Partner-Seite" link to `/partner`, Ordio logo (login, reset), gradient background, Inter font.
- **Login:** Split layout on desktop (form left, enticement panel right); stacked on mobile. Enticement: AFFILIATE_PROGRAM_CLAIM, stats carousel (`partner-stats-carousel.php`), benefit bullets, "Jetzt registrieren" CTA. Stats data in `v2/data/partner-stats.php`; validate with `validate-partner-stats.py`.
- See `docs/systems/affiliate/PARTNER_AUTH_PAGES.md` for full design system.

### Authentication

**Pattern:**

- Use `requireAffiliateAuth()` for protected pages
- Use `isAffiliateAuthenticated()` for conditional checks
- Session timeout: 30 minutes
- Remember me: 30 days cookie; tokens stored in `affiliate_remember_tokens.json` (selector:validator pattern, hashed validator). Invalidated on logout and password change. See `v2/helpers/affiliate-remember-tokens.php`, `docs/systems/affiliate/SECURITY.md`

**Partner registration and terms:**

- Partner registration requires acceptance of the **Partner-Vereinbarung** (Partner terms) and the Datenschutzerklärung. The registration form must include a required checkbox (unchecked by default) with links to `/partner/terms` and `/datenschutz`. Client-side and API validation are mandatory: reject submission if `accept_terms` is not true. Store `terms_accepted_at` (ISO 8601) in the partner JSON for audit. See `docs/systems/affiliate/PARTNER_TERMS.md` and `v2/pages/partner-register.php`, `v2/api/partner-register.php`.

**Login Flow:**

1. Validate credentials
2. Check login attempts (max 5)
3. Verify password with `password_verify()`
4. Set session variables
5. Regenerate session ID
6. Set remember me cookie (if requested); token persisted to JSON via `affiliate-remember-tokens.php`

**Security:**

- Always hash passwords with `password_hash()`
- Use `password_verify()` for authentication
- Regenerate session on login
- Enforce session timeout
- Rate limit login attempts

**Admin (Verwaltung):**

- **Admin tab:** The sidebar shows "Verwaltung" (Admin) only when `isAffiliateAdmin()` is true. Non-admins do not see the link; direct access to `/partner/admin` is protected (redirect to dashboard).
- **Bootstrap admin:** `AFFILIATE_ADMIN_EMAILS` in `v2/config/affiliate-config.php` defines emails that are always admins (e.g. `hady@ordio.com`). No JSON edit is required for the first admin. For robustness, set `is_admin: true` on each config admin’s partner record (via Admin UI “Admin zuweisen” or `php v2/scripts/affiliate/set-partner-admin.php --email=... --is-admin=1`) so the record-based check passes even if session email is missing; see deployment checklist “Bootstrap admin backfill”.
- **Admin check:** `isAffiliateAdmin()` uses (1) config: current partner’s email (from partner record or session) in `AFFILIATE_ADMIN_EMAILS`, or (2) record: partner has `is_admin: true`. When partner is null and session email is empty, the fallback loads the partner from `findReadableAffiliateDataFile()` (same path as `loadPartnerData()`) and sets session email so config check can succeed.
- **Assign/unassign admins:** Admins can set `is_admin` on partner records via the Admin page or the admin update API. At least one admin must remain (config emails + partners with `is_admin`).
- **Admin-only APIs:** All admin APIs (`affiliate-admin-list.php`, `affiliate-admin-update-partner.php`, `affiliate-admin-audit.php`, `affiliate-admin-trigger-sync.php`) must use `affiliate-api-base.php` and `requireAffiliateAuthAPI()`; if not authenticated return 401 JSON, if not admin return 403 JSON. Do not expose admin list/update/audit/sync to non-admins. Admin page fetch calls to these APIs must use `credentials: 'same-origin'` so session cookies are sent. Local dev: serve page and admin APIs from same origin (same port); if 403 on assign-admin, re-login and check server logs for `[Affiliate Admin 403]`.
- **Status re-check:** Auth re-validates partner status on each request; deactivated users lose access on their next request.
- **Reactivate must not bypass verification:** When setting a partner's status to `active` (reactivate), unverified partners (no `email_verified_at`) must be restored to `pending_verification` so they must still verify email before they can log in. Verified partners (with `email_verified_at`) are set to `active`. Optional `force_active: true` in the admin update API allows setting `active` even when unverified (support override; logged in audit). See `v2/api/affiliate-admin-update-partner.php` and TROUBLESHOOTING.md (Partner shows Pending after I reactivated).
- **Audit log:** Admin actions (status change, assign/revoke admin) are logged via `v2/helpers/affiliate-admin-audit.php`; the Admin page shows “Letzte Aktionen” (GET `/v2/api/affiliate-admin-audit.php`, admin only).
- **CSV export:** Admin page “Partner als CSV exportieren” builds CSV client-side from the list API data (no separate export API). CSV and Admin table include a "Letzte Aktivität" column (last_active_at, fallback last_login_at, then "–").
- **Last active / last login:** Admin list API returns `last_login_at` and `last_active_at` (ISO 8601) per partner. These are optional partner JSON fields. `last_login_at` is set on successful login; `last_active_at` is persisted on authenticated requests but **throttled** (e.g. every 15 minutes) via `AFFILIATE_LAST_ACTIVE_UPDATE_INTERVAL` in config to limit JSON write load. Use `v2/helpers/affiliate-last-active.php` for persistence.
- **Admin page styling:** Clean, flat design aligned with Referral-URLs/Leads: no card shadows or card hover. Table uses `admin-partners-table` with min-widths and horizontal scroll so columns are not squished. Styles in `v2/css/affiliate-admin.css` (load `affiliate-admin.min.css` via customHead). Run `npm run minify` after editing. For full pre-deploy: `make pre-deploy`.
- **Admin table actions:** Aktionen column uses icon-only buttons (`.admin-action-btn`) with `title` and `aria-label` for tooltips and accessibility; CSS tooltip on hover/focus via `::after` with `attr(title)`.
- **Confirmations and errors:** Admin page uses **in-platform dialogs** (confirm and alert modals) instead of browser `alert()`/`confirm()`. Load `affiliate-dialogs.min.css` in addition to `affiliate-admin.min.css`. Styles in `v2/css/affiliate-dialogs.css`.
- **Level filter:** Level filter options are Beginner, Starter, Partner, Pro (no "Kein Level"). Null/empty partner level is normalized to Beginner in the admin list API and displayed as Beginner in the table.
- **Manual sync:** Admins can trigger a HubSpot sync from the "Sync mit HubSpot" button on the Admin page. It runs the same sync as cron and uses the same lock so only one sync runs at a time.
- **Partner list search, filter, pagination:** Client-side search (name, email, partner_id), status filter (Alle, Aktiv, Deaktiviert, Ausstehend), and pagination (Pro Seite 10/20/50/100; prev/next, page numbers, jump). Filter bar and pagination mirror Referral-URLs "Seite wählen". CSV export uses filtered list (current search + status).

### Data Display

**KPI Cards:**

- Display key metrics prominently
- Use consistent styling
- Show loading states
- Handle empty data gracefully

**Charts:**

- Use Chart.js for visualizations
- Chart.js is automatically loaded via `affiliate-head.php` when `$chartJs = "true"` is set
- Use shared chart factories from `affiliate-charts.js`:

```javascript
const Charts = window.AffiliateCharts || {};

// MRR line chart
const mrrChart = Charts.createMRRLineChart("canvasId", {
  labels: ["Jan", "Feb", "Mar"],
  values: [100, 150, 200],
});

// MRR donut chart
const donutChart = Charts.createMRRDonutChart("canvasId", {
  active: { mrr: 500, percentage: 80 },
  paused: { mrr: 100, percentage: 16 },
  cancelled: { mrr: 25, percentage: 4 },
});

// Bar chart
const barChart = Charts.createBarChart("canvasId", {
  labels: ["A", "B", "C"],
  values: [10, 20, 30],
  label: "Value",
});
```

- Responsive design
- Accessible colors
- Consistent styling across all charts
- Show empty state when all values are zero; use `Utils.showEmptyState` when available (Dashboard pages load `affiliate-utils.js`).
- Use "Storniert" for cancelled subscription status in charts and tables (not "Gekündigt").

**Data Tables:**

- Sortable columns (when applicable)
- Pagination for large datasets
- Filtering options
- Export functionality (if needed)

**API performance:**
- Consolidate related data in a single response when the same cache/source is used (e.g. dashboard-data includes `recent_leads` to avoid a separate leads fetch).
- Run independent fetches in parallel (e.g. admin list + audit via `Promise.all`).

### API Endpoints

**Use Shared API Base Helper:**

All API endpoints should use `affiliate-api-base.php` for common functionality:

```php
require_once __DIR__ . '/../helpers/affiliate-api-base.php';
require_once __DIR__ . '/../helpers/affiliate-data-formatters.php';

// Authentication and partner ID
$partnerId = requireAffiliateAuthAPI();

// Load cached data
$cacheData = loadCachedHubSpotData();

// Get validated partner
$partner = getAuthenticatedPartnerValidated();

// Use data formatters
$leads = $cacheData['leads'][$partnerId] ?? [];
$formattedLeads = array_map('formatLeadForAPI', $leads);
$leadCounts = calculateLeadCounts($leads);

// Send response
sendJSONResponse(true, ['leads' => $formattedLeads, 'counts' => $leadCounts]);
```

**Response Format:**

Use `sendJSONResponse()` helper instead of manual JSON encoding:

```php
// Success response
sendJSONResponse(true, ['data' => [...], 'last_sync' => $lastSync]);

// Error response
sendJSONResponse(false, null, 404, 'Partner not found');
```

**Error Handling:**

Use `handleAPIError()` helper:

```php
try {
    // API logic
} catch (Exception $e) {
    handleAPIError($e, 'Dashboard data');
}
```

**Data Formatting:**

Use `affiliate-data-formatters.php` functions:

- `formatLeadForAPI($lead)` - Format lead with status determination
- `formatDealForAPI($deal, $partnerId, $partnerLevel)` - Format deal with MRR calculation
- `calculateLeadCounts($leads)` - Count leads by status
- `formatMRRStatusBreakdown($mrrData)` - Format MRR breakdown with percentages
- `calculateConversionRates($leadCounts)` - Calculate conversion rates
- `sortLeadsByDate($leads)` - Sort leads by referral date
- `sortDealsByDate($deals)` - Sort deals by close date
- `sortDealsByMRR($deals, $limit)` - Sort deals by MRR (top N)

### Partner Management

**Registration:**

- Validate all inputs
- Check email uniqueness
- Generate unique Partner ID (AP-YYYYMMDD-XXXXXX)
- Hash password before storage
- Create HubSpot custom object
- Send verification email

**Profile Updates:**

- Allow name changes
- Prevent email/ID changes
- Validate inputs
- Update both JSON and HubSpot

**Password Management:**

- Require current password for changes
- Validate password strength (min 8 chars)
- Hash new passwords
- Support password reset via token

### Gamification

**Badges:**

- Calculate badges based on performance
- Display badges in dashboard
- Update badges after sync
- Use badge definitions from config

**Leaderboard:**

- Show top N partners (default: 10)
- Anonymous mode (hide names)
- Rank by MRR
- Update daily after sync
- **Dein Rang:** Leaderboard API returns `partner_rank` and `partner_mrr` for the current user. Page shows "Dein Rang: #X" card above the list when partner is in top N, or "Du bist #X" line when below top N. API: `v2/api/affiliate-leaderboard.php` (requires auth, returns `partner_rank`, `partner_mrr`).

**Levels:**

- Progression: **Beginner** (0 deals) → Starter (1–5) → Partner (6–10) → Pro (11+). New registrations and pending/0-deal partners use Beginner; do not assign Starter until partner has at least one deal in the last 90 days.
- Calculate based on rolling 90-day performance; update automatically via sync.
- Display level prominently; show level benefits.
- **Admin override:** Admins can set `level_override` and optional `level_override_until` for partners (e.g. strategic partners). Use `getEffectivePartnerLevel()` for display, MRR, and HubSpot sync. See LEVEL_CALCULATION.md and ARCHITECTURE.md.
- **Earnings API:** `v2/api/affiliate-earnings.php` **must** use `getEffectivePartnerLevel()`, not `$partner['level'] ?? 'beginner'`, for MRR calculation and deal formatting. Partners with manual level override otherwise get incorrect MRR (wrong share %).

**Levels Page (partner-levels.php) – 90-day rolling display:**

- **Hero card:** Shows current level, MRR share, progress to next level (progress bar, deals in period, deals needed). Third stat: **Berechnungszeitraum** with actual date range (`period.start_formatted – period.end_formatted`), not "Letzte 90 Tage"/"Zeitraum". No quarter countdown: `days_remaining` is null for rolling 90-day logic.
- **Manual override badge:** When `partner.level_override` is true, show "manuell" badge next to level name in hero.
- **No arbitrary milestones:** Progress bar has no 33/66/100% markers; only fill based on `progress_percentage`.
- **Variable usage:** Prefer `current_deals_in_period`; `current_quarter_deals` is deprecated in API.
- **Achievement dates:** Badge system does not track `earned_at`; use "Erreicht" label, not fake dates.

### Referral / Empfehlungs-URLs

- **Promotable paths:** Use only paths from config (`getAffiliatePromotablePaths()` in `v2/config/affiliate-config.php`). Do not hardcode paths on the page or in the API.
- **URL generation:** `generateAffiliateReferralUrl($partnerId, $utmParams, $path)` – path must be in allowlist; default path = homepage.
- **UTM parameters:** Pass all five (utm_source, utm_medium, utm_campaign, utm_content, utm_term) to the API when generating URLs; API accepts optional path + all UTM params.
- **API:** `POST /v2/api/affiliate-generate-url.php` – validates path; returns 400 for invalid path.
- **Assets:** Partner-facing assets (logo, copy) live in `v2/img/affiliate/`; link from "Material & Vorlagen" block. See `docs/systems/affiliate/REFERRAL_PAGE_GUIDE.md`.

### File Storage

**Multi-Tier Fallback:**

1. Check writable location first
2. Fallback to readable location
3. Use `findWritableAffiliateDataFile()` for writes
4. Use `findReadableAffiliateDataFile()` for reads

**Atomic Writes:**

- Write to `.tmp` file first
- Rename after successful write
- Handle errors gracefully
- Preserve data integrity

## Best Practices

### Security

- **Never expose passwords** in logs or responses
- **Validate all inputs** before processing
- **Sanitize outputs** with `htmlspecialchars()`
- **Use prepared statements** if SQL is used (not applicable for JSON storage)
- **Rate limit** sensitive operations
- **Regenerate sessions** on authentication

### Performance

- **Cache HubSpot data** locally
- **Load data from cache** for dashboard
- **Use async API calls** where possible
- **Optimize JSON parsing** (validate before decode)
- **Minimize API calls** to HubSpot

### Error Handling

- **Log all errors** with context
- **Return user-friendly messages**
- **Never expose internals** in error responses
- **Handle edge cases** gracefully
- **Validate data** before processing

### Code Quality

- **Follow existing patterns** from Product Updates Admin
- **Use consistent naming** conventions
- **Document complex logic**
- **Test edge cases**
- **Handle null/empty values**

### Code Organization & Modularization

**Use Shared Components:**

1. **API Layer:** Always use `affiliate-api-base.php` helpers:

   - `requireAffiliateAuthAPI()` - Authentication wrapper
   - `loadCachedHubSpotData()` - Cache loading
   - `sendJSONResponse()` - JSON responses
   - `handleAPIError()` - Error handling

2. **Data Formatting:** Use `affiliate-data-formatters.php`:

   - `formatLeadForAPI()`, `formatDealForAPI()`
   - `calculateLeadCounts()`, `formatMRRStatusBreakdown()`
   - Sorting utilities: `sortLeadsByDate()`, `sortDealsByMRR()`

3. **CSS:** Use `affiliate-shared.css` for common styles:

   - CSS custom properties (`:root` variables)
   - Common components (cards, buttons, tables, sidebar)
   - Only add page-specific styles in page files

4. **JavaScript:** Use `affiliate-utils.js` and `affiliate-charts.js`:

   - `window.AffiliateUtils.escapeHtml()`, `formatCurrency()`, `formatDate()`
   - `window.AffiliateUtils.handleAPIError()`, `showLoadingState()`, `showEmptyState()`
   - `window.AffiliateCharts.createMRRLineChart()`, `createMRRDonutChart()`, etc.

5. **Page Templates:** Use `renderAffiliatePage()` for new pages:
   - Reduces boilerplate code
   - Ensures consistent structure
   - Handles authentication, head, sidebar automatically

**Avoid Duplication:**

- ❌ Don't duplicate `loadCachedHubSpotData()` function
- ❌ Don't duplicate CSS variables or common styles
- ❌ Don't duplicate JavaScript utility functions
- ❌ Don't duplicate error handling patterns
- ✅ Use shared helpers and utilities
- ✅ Extend shared CSS with page-specific styles only
- ✅ Use shared JavaScript utilities with fallbacks

## Common Patterns

### Loading Partner Data

**In API Endpoints:**

```php
require_once __DIR__ . '/../helpers/affiliate-api-base.php';

$partnerId = requireAffiliateAuthAPI(); // Handles auth and returns partner ID
$partner = getAuthenticatedPartnerValidated(); // Validates and returns partner or sends 404
```

**In Page Files:**

```php
require_once __DIR__ . '/../includes/affiliate-auth.php';

requireAffiliateAuth();
$partner = getAuthenticatedPartner();
if (!$partner) {
    header('Location: /v2/pages/partner-login.php');
    exit;
}
$partnerId = $partner['partner_id'];
```

### Fetching Dashboard Data

**JavaScript (Frontend):**

```javascript
const Utils = window.AffiliateUtils || {};

async function loadDashboardData() {
  try {
    const response = await fetch("/v2/api/affiliate-dashboard-data.php");
    const result = await response.json();

    if (result.success && result.data) {
      // Use data
    } else {
      Utils.handleAPIError(result.error, container, "Dashboard");
    }
  } catch (error) {
    Utils.handleAPIError(error, container, "Dashboard");
  }
}
```

**PHP (Backend API):**

```php
require_once __DIR__ . '/../helpers/affiliate-api-base.php';
require_once __DIR__ . '/../helpers/affiliate-data-formatters.php';

$partnerId = requireAffiliateAuthAPI();
$cacheData = loadCachedHubSpotData();
$partner = getAuthenticatedPartnerValidated();

// Process and format data using formatters
$leads = $cacheData['leads'][$partnerId] ?? [];
$formattedLeads = array_map('formatLeadForAPI', $leads);

sendJSONResponse(true, ['leads' => $formattedLeads]);
```

### Updating Profile

```php
// Validate input
$name = trim($_POST['name'] ?? '');
if (empty($name) || strlen($name) < 2) {
    // Return error
}

// Update via API or direct
// Save to JSON and HubSpot
```

## Standalone Architecture

The affiliate partner system is completely separated from the main marketing website:

- **Independent Head Section:** Uses `affiliate-head.php` instead of `base/head.php`
- **No Marketing Dependencies:** No tracking scripts, Tailwind CSS, AOS, Swiper, etc.
- **Clean Separation:** Partner pages function as standalone product
- **Easier Maintenance:** Changes to marketing site don't affect partner system
- **Better Performance:** Only loads necessary resources for partner pages

See `docs/systems/affiliate/STANDALONE_ARCHITECTURE.md` for complete documentation.

## Resources Library

The Resources Library provides affiliates with marketing assets (images, videos, PDFs) for promotion. **Assets link to Ordio pages (not direct asset URLs)** following affiliate marketing best practices.

### Page Structure

**File:** `v2/pages/partner-resources.php`

**Pattern:**
```php
require_once __DIR__ . '/../helpers/partner-resources.php';

// Load resources for filter options
$allResources = loadPartnerResources();
$resources = $allResources['resources'] ?? [];

// Extract unique values for filters
$products = [];
$tags = [];
$dimensions = [];
// ... populate filter arrays

// Page styles and scripts
$resourcesCssMin = __DIR__ . '/../css/affiliate-resources.min.css';
$resourcesJsMin = __DIR__ . '/../js/affiliate-resources.min.js';
$partnerId = $partner['partner_id'] ?? '';
$pageStyles = '<link rel="stylesheet" href="/v2/css/affiliate-resources.min.css?v=' . filemtime($resourcesCssMin) . '">';
$pageScripts = '<script>window.affiliatePartnerId = ' . json_encode($partnerId, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) . ';</script>' .
               '<script src="/v2/js/affiliate-resources.min.js?v=' . filemtime($resourcesJsMin) . '"></script>';

renderAffiliatePage('Resources', 'resources', $pageContent, [
    'customHead' => $pageStyles,
    'customScripts' => $pageScripts
]);
```

### Helper Functions

**File:** `v2/helpers/partner-resources.php`

- `loadPartnerResources()` - Load resources JSON
- `savePartnerResources($data)` - Save with atomic write
- `getResourceById($id)` - Get single resource
- `getResourcesByFilter($filters)` - Filter resources
- `findWritablePartnerResourcesFile()` - Find writable location
- `findReadablePartnerResourcesFile()` - Find readable location

### API Endpoints

**List Resources:** `GET /v2/api/partner-resources.php`
- Query params: `format`, `product`, `tag`, `orientation` (square, horizontal, vertical), `search`, `limit`, `offset`
- Returns paginated, filtered resources

**Download:** `GET /v2/api/partner-resource-download.php`
- Query params: `resource_id`
- Returns clean download URL (no tracking) for actual file downloads

**Link Generation:** `POST /v2/api/partner-resource-link.php`
- Body: `resource_id`, `landing_page` (path), `custom_url` (ordio.com URL), UTM parameters, `custom_tracking_params`
- Returns tracking URL (points to Ordio page, not asset URL) and embed code
- For images: Returns clean image URL separately
- For PDFs: Returns landing page link; PDFs with hosted file also get embed code (iframe + CTA link for affiliate tracking)
- For videos: Returns YouTube embed URL + tracking link separately

### Link/Embed Behavior

**Best Practices:**
- **Images**: Wrapped in `<a>` tags - tracking link in `href`, clean image URL in `src`
- **PDFs**: Modal shows iframe for scrollable PDF viewing; embed code is iframe + CTA link; PDFs without hosted file show tracking URL only
- **Videos**: YouTube iframe remains clean, tracking link provided separately

**Page Selector:**
- Default: Homepage (`/`)
- Partners can select from promotable pages (via sitemap API)
- Custom URL option: Enter any `ordio.com` URL

**Tracking Parameters:**
- Optional toggle for additional UTM parameters
- Custom tracking parameters support (key-value pairs)
- Affiliate ID always included automatically

### Adding Resources

**Images:**
```bash
# Root assets
python3 v2/scripts/partner/analyze-assets.py

# 2026 subfolder
python3 v2/scripts/partner/analyze-assets.py --dir v2/img/partner/assets/2026 --output-subdir 2026
```

**Videos:**
```bash
php v2/scripts/partner/extract-youtube-videos.php
```

**PDFs:**
```bash
php v2/scripts/partner/add-pdf-resource.php
```

### Resource Schema

```json
{
  "id": "resource-id",
  "type": "image|video|pdf",
  "title": "Asset Title",
  "description": "Description",
  "filename": "file.ext",
  "file_path": "/v2/img/partner/assets/file.ext",
  "url": "https://www.ordio.com/v2/img/partner/assets/file.ext",
  "preview_image": "/v2/img/partner/assets/deck-cover.webp",
  "dimensions": { "width": 1200, "height": 628 },
  "tags": ["Tag1", "Tag2"],
  "products": ["schichtplanung"],
  "youtube_id": null,
  "embed_code": "<iframe>...</iframe>"
}
```

See `docs/systems/affiliate/RESOURCES_LIBRARY.md` for complete documentation.

## Program analytics (admin-only)

- **Page:** `/partner/program-analytics` → `v2/pages/partner-program-analytics.php` (rewrite in `.htaccess`). Non-admins redirect to `/partner/dashboard`.
- **API:** `v2/api/affiliate-admin-program-metrics.php` — `requireAffiliateAuthAPI()` + `isAffiliateAdmin()`; **403** for non-admins. **No live HubSpot** on request; only `loadPartnerData()` + `loadCachedHubSpotData()` via `affiliate-program-aggregates.php`.
- **UI:** `v2/js/affiliate-program-analytics.js`, `v2/css/affiliate-program-analytics.css` (minified siblings). Chart.js factories: `AffiliateCharts` from `affiliate-charts.js`. Do not expose HubSpot tokens or secrets in client code.
- **Loading / Chart.js:** `#program-analytics-loading` must be hidden on **every** outcome: successful load, API error, abort/timeout, non-JSON body, missing `Chart` or `AffiliateCharts` on init, and 401 (before redirect). Never leave an indefinite spinner if the CDN or network fails.
- **Copy on page:** Program analytics UI uses **business German** (pipeline, Empfehlungen, Deals im KPI-Sinn). Keep **cache/sync/UTC/raw-deal** language in [PROGRAM_ANALYTICS_GUIDE.md](../../docs/systems/affiliate/PROGRAM_ANALYTICS_GUIDE.md) and DATA_GLOSSARY, not as primary on-screen KPI copy.
- **PII / exports:** Response includes aggregates plus **period lead/deal lists** (`period_leads`, `period_deals`, capped per `AFFILIATE_PROGRAM_ANALYTICS_LIST_MAX` in `affiliate-program-aggregates.php`); top-partner tables list names. Same admin audience as Verwaltung. Any future CSV/export must follow partner-admin masking policy and stay admin-gated.
- **Docs:** [PROGRAM_ANALYTICS_GUIDE.md](../../docs/systems/affiliate/PROGRAM_ANALYTICS_GUIDE.md), [CACHE_ARCHITECTURE.md](../../docs/systems/affiliate/CACHE_ARCHITECTURE.md) (consumer row; trends = timestamp reconstruction).

## Related Documentation

- **[Partner Guide](../../docs/systems/affiliate/PARTNER_GUIDE.md)** - User guide
- **[Dashboard Guide](../../docs/systems/affiliate/DASHBOARD_GUIDE.md)** - Dashboard usage
- **[Program analytics](../../docs/systems/affiliate/PROGRAM_ANALYTICS_GUIDE.md)** - Admin program metrics (cache-only)
- **[Architecture](../../docs/systems/affiliate/ARCHITECTURE.md)** - Technical architecture
- **[Standalone Architecture](../../docs/systems/affiliate/STANDALONE_ARCHITECTURE.md)** - Standalone system documentation
- **[API Reference](../../docs/systems/affiliate/API_REFERENCE.md)** - API endpoints
- **[Resources Library](../../docs/systems/affiliate/RESOURCES_LIBRARY.md)** - Resources library documentation
