# App Ad Serving — Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Serve banner ads in the HBCU Connect mobile app between feed posts, job listings, member cards, and shop products using the existing ad server via WebView.

**Architecture:** Reusable `AdBanner` component wraps a WebView loading `ad_server.cgi?zone_id=N`. An `insertAds()` helper splices ad placeholders into data arrays at every 10th position. Link clicks open a full-screen `InAppBrowser` modal. Four new ad zones in the DB provide separate app inventory.

**Tech Stack:** React Native, react-native-webview, Expo, existing Perl ad_server.cgi

---

### Task 1: Install react-native-webview

**Files:**
- Modify: `hbcu-app/mobile/package.json`

**Step 1: Install the package**

Run from the mobile directory:
```bash
cd /var/www/vhosts/hbcuconnect.com/httpdocs/hbcu-app/mobile
npx expo install react-native-webview
```

**Step 2: Verify it installed**

Run: `node -e "require('react-native-webview')" && echo "OK"`
Expected: OK (no errors)

---

### Task 2: Create Ad Zones in Database

**Step 1: Insert the 4 app ad zones**

```bash
mysql -h newdb.hbcuconnect.com -u hbcuconnect -p'$DB_PASS' career_center -e "
INSERT INTO banner_zones (zone_name, zone_description, width, height, ad_types, active)
VALUES
  ('App - Feed', 'HBCU App: between posts in social feed', 300, 250, 'html,image,remote', 1),
  ('App - Jobs', 'HBCU App: between job listings', 300, 250, 'html,image,remote', 1),
  ('App - Members', 'HBCU App: between member cards', 300, 250, 'html,image,remote', 1),
  ('App - Shop', 'HBCU App: between product listings', 300, 250, 'html,image,remote', 1);
"
```

**Step 2: Retrieve the assigned zone IDs**

```bash
mysql -h newdb.hbcuconnect.com -u hbcuconnect -p'$DB_PASS' career_center -e "
SELECT zone_id, zone_name FROM banner_zones WHERE zone_name LIKE 'App -%' ORDER BY zone_id;
"
```

Record the zone_id values — they'll be used as constants in `adHelpers.js`.

---

### Task 3: Create `adHelpers.js` Utility

**Files:**
- Create: `hbcu-app/mobile/src/utils/adHelpers.js`

**Step 1: Create the helper file**

```javascript
// Ad insertion helpers for FlatList data arrays

// Zone IDs — set after running the SQL in Task 2
export const AD_ZONES = {
  FEED: __FEED_ZONE_ID__,       // Replace with actual zone_id from DB
  JOBS: __JOBS_ZONE_ID__,
  MEMBERS: __MEMBERS_ZONE_ID__,
  SHOP: __SHOP_ZONE_ID__,
};

// Ad size presets
export const AD_SIZES = {
  'medium-rectangle': { width: 300, height: 250 },
  'full-banner': { width: 468, height: 60 },
};

/**
 * Insert ad placeholders into a data array at regular intervals.
 *
 * @param {Array} items - The real data items
 * @param {number} zoneId - Ad zone ID to load
 * @param {number} interval - Insert ad every N items (default 10)
 * @returns {Array} New array with ad placeholder objects spliced in
 */
export function insertAds(items, zoneId, interval = 10) {
  if (!items || items.length === 0) return items;

  const result = [];
  let adCounter = 0;

  for (let i = 0; i < items.length; i++) {
    // Insert ad before every Nth item (at positions 10, 20, 30, ...)
    if (i > 0 && i % interval === 0) {
      result.push({
        _isAd: true,
        _adKey: `ad-${zoneId}-${adCounter++}`,
        zoneId,
      });
    }
    result.push(items[i]);
  }

  return result;
}
```

**Step 2: Verify syntax**

Run: `node -e "require('./hbcu-app/mobile/src/utils/adHelpers.js')" && echo "OK"`

**Step 3: Set file ownership**

```bash
chown hbcuconnect:psacln hbcu-app/mobile/src/utils/adHelpers.js
```

---

### Task 4: Create `InAppBrowser.js` Component

**Files:**
- Create: `hbcu-app/mobile/src/components/InAppBrowser.js`

**Step 1: Create the component**

```javascript
import React, { useRef, useState } from 'react';
import {
  View, Text, Modal, TouchableOpacity, StyleSheet,
  SafeAreaView, ActivityIndicator, Linking, Platform,
} from 'react-native';
import { WebView } from 'react-native-webview';
import { Ionicons } from '@expo/vector-icons';
import { colors } from '../context/theme';

export default function InAppBrowser({ visible, url, onClose }) {
  const webViewRef = useRef(null);
  const [canGoBack, setCanGoBack] = useState(false);
  const [canGoForward, setCanGoForward] = useState(false);
  const [currentUrl, setCurrentUrl] = useState(url);
  const [loading, setLoading] = useState(true);

  // Extract domain for display
  const displayDomain = (() => {
    try {
      return new URL(currentUrl || url).hostname;
    } catch {
      return '';
    }
  })();

  const openExternal = () => {
    Linking.openURL(currentUrl || url);
    onClose(); // Close modal so user returns to app, not modal
  };

  return (
    <Modal visible={visible} animationType="slide" onRequestClose={onClose}>
      <SafeAreaView style={styles.container}>
        {/* Top bar */}
        <View style={styles.topBar}>
          <View style={styles.urlContainer}>
            <Ionicons name="lock-closed" size={12} color={colors.textLight} />
            <Text style={styles.urlText} numberOfLines={1}>{displayDomain}</Text>
          </View>
          <TouchableOpacity onPress={onClose} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
            <Ionicons name="close" size={24} color={colors.text} />
          </TouchableOpacity>
        </View>

        {/* Loading bar */}
        {loading && <View style={styles.loadingBar}><ActivityIndicator size="small" color={colors.primary} /></View>}

        {/* WebView */}
        <WebView
          ref={webViewRef}
          source={{ uri: url }}
          style={styles.webview}
          onNavigationStateChange={(navState) => {
            setCanGoBack(navState.canGoBack);
            setCanGoForward(navState.canGoForward);
            setCurrentUrl(navState.url);
            setLoading(navState.loading);
          }}
          onLoadStart={() => setLoading(true)}
          onLoadEnd={() => setLoading(false)}
          startInLoadingState={false}
          javaScriptEnabled={true}
          domStorageEnabled={true}
          allowsInlineMediaPlayback={true}
        />

        {/* Bottom toolbar */}
        <View style={styles.bottomBar}>
          <TouchableOpacity
            onPress={() => webViewRef.current?.goBack()}
            disabled={!canGoBack}
            style={styles.navButton}
          >
            <Ionicons name="chevron-back" size={22} color={canGoBack ? colors.primary : colors.border} />
          </TouchableOpacity>

          <TouchableOpacity
            onPress={() => webViewRef.current?.goForward()}
            disabled={!canGoForward}
            style={styles.navButton}
          >
            <Ionicons name="chevron-forward" size={22} color={canGoForward ? colors.primary : colors.border} />
          </TouchableOpacity>

          <View style={{ flex: 1 }} />

          <TouchableOpacity onPress={openExternal} style={styles.openButton}>
            <Ionicons name="open-outline" size={18} color={colors.primary} />
            <Text style={styles.openText}>Open in Browser</Text>
          </TouchableOpacity>
        </View>
      </SafeAreaView>
    </Modal>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: colors.surface,
  },
  topBar: {
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'space-between',
    paddingHorizontal: 16,
    paddingVertical: 10,
    borderBottomWidth: 1,
    borderBottomColor: colors.border,
  },
  urlContainer: {
    flex: 1,
    flexDirection: 'row',
    alignItems: 'center',
    gap: 6,
    marginRight: 16,
  },
  urlText: {
    fontSize: 13,
    color: colors.textSecondary,
  },
  loadingBar: {
    height: 2,
    backgroundColor: colors.background,
  },
  webview: {
    flex: 1,
  },
  bottomBar: {
    flexDirection: 'row',
    alignItems: 'center',
    paddingHorizontal: 16,
    paddingVertical: 10,
    borderTopWidth: 1,
    borderTopColor: colors.border,
    gap: 8,
  },
  navButton: {
    padding: 6,
  },
  openButton: {
    flexDirection: 'row',
    alignItems: 'center',
    gap: 6,
    paddingVertical: 6,
    paddingHorizontal: 12,
    borderRadius: 6,
    backgroundColor: colors.background,
  },
  openText: {
    fontSize: 13,
    color: colors.primary,
    fontWeight: '500',
  },
});
```

**Step 2: Set file ownership**

```bash
chown hbcuconnect:psacln hbcu-app/mobile/src/components/InAppBrowser.js
```

---

### Task 5: Create `AdBanner.js` Component

**Files:**
- Create: `hbcu-app/mobile/src/components/AdBanner.js`

**Step 1: Create the component**

```javascript
import React, { useState } from 'react';
import { View, Text, StyleSheet, ActivityIndicator } from 'react-native';
import { WebView } from 'react-native-webview';
import InAppBrowser from './InAppBrowser';
import { AD_SIZES } from '../utils/adHelpers';
import { colors } from '../context/theme';

const AD_SERVER_BASE = 'https://hbcuconnect.com/cgi-bin/ads/ad_server.cgi';

export default function AdBanner({ zoneId, size = 'medium-rectangle' }) {
  const [browserUrl, setBrowserUrl] = useState(null);
  const [loaded, setLoaded] = useState(false);

  const dimensions = AD_SIZES[size] || AD_SIZES['medium-rectangle'];
  const adUrl = `${AD_SERVER_BASE}?zone_id=${zoneId}`;

  // Intercept navigation — open links in InAppBrowser instead
  const handleNavigationRequest = (request) => {
    // Allow the initial ad load
    if (request.url === adUrl || request.url.startsWith(AD_SERVER_BASE + '?zone_id=')) {
      return true;
    }
    // All other navigation (ad clicks) open in the in-app browser
    setBrowserUrl(request.url);
    return false;
  };

  return (
    <View style={[styles.container, { height: dimensions.height + 20 }]}>
      {/* "Ad" label */}
      <Text style={styles.adLabel}>Ad</Text>

      {/* Loading placeholder */}
      {!loaded && (
        <View style={[styles.placeholder, { width: dimensions.width, height: dimensions.height }]}>
          <ActivityIndicator size="small" color={colors.border} />
        </View>
      )}

      {/* Ad WebView */}
      <WebView
        source={{ uri: adUrl }}
        style={[
          styles.webview,
          { width: dimensions.width, height: dimensions.height },
          !loaded && { opacity: 0, position: 'absolute' },
        ]}
        onShouldStartLoadWithRequest={handleNavigationRequest}
        onLoad={() => setLoaded(true)}
        scrollEnabled={false}
        bounces={false}
        javaScriptEnabled={true}
        showsHorizontalScrollIndicator={false}
        showsVerticalScrollIndicator={false}
        scalesPageToFit={false}
        allowsInlineMediaPlayback={true}
      />

      {/* In-App Browser Modal */}
      <InAppBrowser
        visible={!!browserUrl}
        url={browserUrl || ''}
        onClose={() => setBrowserUrl(null)}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: colors.background,
    paddingVertical: 10,
  },
  adLabel: {
    position: 'absolute',
    top: 10,
    right: 12,
    fontSize: 9,
    color: colors.textLight,
    fontWeight: '600',
    letterSpacing: 0.5,
    zIndex: 1,
  },
  placeholder: {
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: colors.background,
    borderRadius: 4,
  },
  webview: {
    backgroundColor: 'transparent',
    borderRadius: 4,
    overflow: 'hidden',
  },
});
```

**Step 2: Set file ownership**

```bash
chown hbcuconnect:psacln hbcu-app/mobile/src/components/AdBanner.js
```

---

### Task 6: Integrate Ads into FeedScreen

**Files:**
- Modify: `hbcu-app/mobile/src/screens/media/FeedScreen.js`

**Step 1: Add imports**

At the top of the file, after existing imports, add:

```javascript
import AdBanner from '../../components/AdBanner';
import { insertAds, AD_ZONES } from '../../utils/adHelpers';
```

**Step 2: Modify FlatList data and renderItem**

Change the FlatList's `data` prop from `posts` to `insertAds(posts, AD_ZONES.FEED)`.

Change `keyExtractor` to handle both posts and ad items:
```javascript
keyExtractor={item => item._isAd ? item._adKey : String(item.media_id)}
```

Wrap the existing `renderItem` to check for ads:
```javascript
renderItem={({ item }) => {
  if (item._isAd) return <AdBanner zoneId={item.zoneId} />;
  return renderPost({ item });
}}
```

---

### Task 7: Integrate Ads into JobListScreen

**Files:**
- Modify: `hbcu-app/mobile/src/screens/jobs/JobListScreen.js`

**Step 1: Add imports**

```javascript
import AdBanner from '../../components/AdBanner';
import { insertAds, AD_ZONES } from '../../utils/adHelpers';
```

**Step 2: Modify FlatList**

Change `data` from `jobs` to `insertAds(jobs, AD_ZONES.JOBS)`.

Change `keyExtractor`:
```javascript
keyExtractor={item => item._isAd ? item._adKey : String(item.job_id)}
```

Wrap `renderItem`:
```javascript
renderItem={({ item }) => {
  if (item._isAd) return <AdBanner zoneId={item.zoneId} />;
  return renderJob({ item });
}}
```

---

### Task 8: Integrate Ads into MemberSearchScreen

**Files:**
- Modify: `hbcu-app/mobile/src/screens/members/MemberSearchScreen.js`

**Step 1: Add imports**

```javascript
import AdBanner from '../../components/AdBanner';
import { insertAds, AD_ZONES } from '../../utils/adHelpers';
```

**Step 2: Modify FlatList**

Change `data` from `members` to `insertAds(members, AD_ZONES.MEMBERS)`.

Change `keyExtractor`:
```javascript
keyExtractor={item => item._isAd ? item._adKey : String(item.registry_id)}
```

Wrap `renderItem`:
```javascript
renderItem={({ item }) => {
  if (item._isAd) return <AdBanner zoneId={item.zoneId} />;
  return renderMember({ item });
}}
```

---

### Task 9: Integrate Ads into CategoryProductsScreen (2-Column Grid)

**Files:**
- Modify: `hbcu-app/mobile/src/screens/marketplace/CategoryProductsScreen.js`

This is the trickiest screen because it uses `numColumns={2}`. A full-width ad needs to break out of the 2-column grid.

**Step 1: Add imports**

```javascript
import AdBanner from '../../components/AdBanner';
import { insertAds, AD_ZONES } from '../../utils/adHelpers';
```

**Step 2: Switch from numColumns to manual row layout**

The `numColumns` prop forces every item into the grid. To allow full-width ads to break out, we switch to a single-column FlatList that manually renders product pairs as rows and ads as full-width items.

Create a helper function that groups products into rows of 2, with ads as standalone items:

```javascript
function groupIntoRows(items) {
  const rows = [];
  let productBuffer = [];

  for (const item of items) {
    if (item._isAd) {
      // Flush any buffered products as a row first
      if (productBuffer.length > 0) {
        rows.push({ _isRow: true, _rowKey: `row-${rows.length}`, products: [...productBuffer] });
        productBuffer = [];
      }
      rows.push(item); // Ad as its own row
    } else {
      productBuffer.push(item);
      if (productBuffer.length === 2) {
        rows.push({ _isRow: true, _rowKey: `row-${rows.length}`, products: [...productBuffer] });
        productBuffer = [];
      }
    }
  }
  // Flush remaining
  if (productBuffer.length > 0) {
    rows.push({ _isRow: true, _rowKey: `row-${rows.length}`, products: [...productBuffer] });
  }

  return rows;
}
```

**Step 3: Update FlatList**

Remove `numColumns={2}` and `columnWrapperStyle`. Change:

```javascript
<FlatList
  data={groupIntoRows(insertAds(products, AD_ZONES.SHOP))}
  keyExtractor={item => item._isAd ? item._adKey : item._isRow ? item._rowKey : String(item.listing_id)}
  renderItem={({ item }) => {
    if (item._isAd) return <AdBanner zoneId={item.zoneId} />;
    if (item._isRow) {
      return (
        <View style={styles.row}>
          {item.products.map(product => (
            <View key={product.listing_id}>{renderProduct({ item: product })}</View>
          ))}
        </View>
      );
    }
    return renderProduct({ item });
  }}
  contentContainerStyle={styles.listContent}
  refreshControl={...}
  onEndReached={handleLoadMore}
  onEndReachedThreshold={0.5}
  ListFooterComponent={...}
  ListEmptyComponent={...}
/>
```

The existing `styles.row` (`justifyContent: 'flex-start'`) stays the same.

---

### Task 10: Update Zone IDs and Verify

**Step 1: Update adHelpers.js with actual zone IDs**

After Task 2 provides the zone_id values from the DB, replace the `__FEED_ZONE_ID__` etc. placeholders in `adHelpers.js` with the real numbers.

**Step 2: Rebuild the ad rotation cache**

The ad server uses `dynamic_ads.pl` which pre-computes banner/zone arrays. This runs hourly via cron. To see the new zones immediately:

```bash
cd /var/www/vhosts/hbcuconnect.com/httpdocs/cgi-bin/ads
perl dynamic_ads.pl
```

**Step 3: Verify ad serving works for new zones**

Open in browser:
```
https://hbcuconnect.com/cgi-bin/ads/ad_server.cgi?zone_id=<FEED_ZONE_ID>
```

Expected: An HTML page with an ad (or a blank page if no banners assigned to the zone yet — that's OK, the ad server returns an empty page for zones with no active banners).

**Step 4: Test the app**

- Open the app, scroll through the Feed — ad should appear after every 10th post
- Go to Jobs tab, scroll — ad after every 10th job
- Go to Members tab, search — ad after every 10th member
- Go to Shop, browse a category — ad after every 10th product, spanning full width
- Tap an ad — InAppBrowser modal opens
- Tap "Open in Browser" — external browser opens, modal closes
- Tap X — modal closes, back to list

---

## File Summary

| Action | File |
|--------|------|
| Install | `react-native-webview` via npx expo install |
| SQL | INSERT 4 rows into `career_center.banner_zones` |
| Create | `hbcu-app/mobile/src/utils/adHelpers.js` |
| Create | `hbcu-app/mobile/src/components/InAppBrowser.js` |
| Create | `hbcu-app/mobile/src/components/AdBanner.js` |
| Modify | `hbcu-app/mobile/src/screens/media/FeedScreen.js` |
| Modify | `hbcu-app/mobile/src/screens/jobs/JobListScreen.js` |
| Modify | `hbcu-app/mobile/src/screens/members/MemberSearchScreen.js` |
| Modify | `hbcu-app/mobile/src/screens/marketplace/CategoryProductsScreen.js` |
