# App Clip Deployment Guide

This guide walks you through setting up the domain, Cloudflare Worker, and App Store Connect configuration needed for the BusinessCard App Clip to work.

**Total Cost: ~$10-15/year** (domain only - everything else is free)
**Time: ~1-2 hours**

---

## Overview

```
User scans QR code
        ↓
https://yourdomain.com/card/abc123
        ↓
Cloudflare Worker receives request
        ↓
┌───────────────────────────────────────┐
│  Serves AASA file to Apple            │
│  (verifies App Clip ownership)        │
└───────────────────────────────────────┘
        ↓
┌───────────┴───────────┐
↓                       ↓
iOS                   Android/Browser
↓                       ↓
App Clip launches     Worker fetches vCard
(native experience)   from CloudKit, returns
                      .vcf file download
```

---

## Phase 1: Register a Domain (~10 minutes)

### Step 1.1: Choose a Domain Name

Pick something short and memorable. Examples:
- `cards.yourlastname.com` (if you own a personal domain)
- `bizcards.io`
- `cardshare.app`
- `vcards.link`

### Step 1.2: Register the Domain

**Recommended Registrars** (cheapest, no upselling):

| Registrar | .com Price | .io Price | .app Price |
|-----------|------------|-----------|------------|
| [Cloudflare Registrar](https://www.cloudflare.com/products/registrar/) | ~$10/yr | ~$35/yr | ~$15/yr |
| [Porkbun](https://porkbun.com) | ~$10/yr | ~$33/yr | ~$15/yr |
| [Namecheap](https://namecheap.com) | ~$11/yr | ~$35/yr | ~$16/yr |

**Recommendation**: Register directly with Cloudflare - simplest setup since you'll use Cloudflare Workers anyway.

### Step 1.3: If Not Using Cloudflare Registrar

If you registered elsewhere, add the domain to Cloudflare:

1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com)
2. Click "Add a Site"
3. Enter your domain
4. Select the **Free** plan
5. Cloudflare will give you 2 nameservers (e.g., `ada.ns.cloudflare.com`)
6. Go to your registrar and update nameservers to Cloudflare's
7. Wait for DNS propagation (5 min - 24 hours, usually fast)

---

## Phase 2: Create CloudKit Public Database Schema (~15 minutes)

The App Clip needs to read shared cards from CloudKit's public database.

### Step 2.1: Open CloudKit Console

1. Go to [CloudKit Console](https://icloud.developer.apple.com/dashboard)
2. Sign in with your Apple Developer account
3. Select your container: `iCloud.com.mbrucedogs.BusinessCard`

### Step 2.2: Create the Record Type

1. Click **Schema** in the sidebar
2. Click **Record Types**
3. Click **+** to create new record type
4. Name: `SharedCard`
5. Add these fields:

| Field Name | Type |
|------------|------|
| `vCardData` | String |
| `expiresAt` | Date/Time |
| `createdAt` | Date/Time |

6. Click **Save**

### Step 2.3: Set Security Roles (IMPORTANT)

1. Click **Security Roles** under your record type
2. For the **Public** database:
   - **Authenticated Users**: Read, Write (for uploading from main app)
   - **World**: Read (for App Clip to fetch without sign-in)

3. Click **Save**

### Step 2.4: Deploy to Production

1. Click **Deploy Schema to Production...**
2. Confirm the deployment

---

## Phase 3: Set Up Cloudflare Worker (~30 minutes)

### Step 3.1: Create the Worker

1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com)
2. Click **Workers & Pages** in the sidebar
3. Click **Create Application**
4. Click **Create Worker**
5. Name it: `businesscard-share` (or similar)
6. Click **Deploy** (creates with placeholder code)

### Step 3.2: Configure Worker Code

1. Click **Edit Code** (or "Quick Edit")
2. Replace all code with:

```javascript
// BusinessCard App Clip & vCard Worker
// Handles iOS App Clip verification and Android vCard downloads

// ============================================================================
// CONFIGURATION - Update these values
// ============================================================================

const CONFIG = {
  // Your Apple Developer Team ID (find in Apple Developer portal)
  teamId: '6R7KLBPBLZ',
  
  // Your App Clip bundle identifier
  appClipBundleId: 'com.mbrucedogs.BusinessCard.Clip',
  
  // Your CloudKit container identifier
  cloudKitContainer: 'iCloud.com.mbrucedogs.BusinessCard',
  
  // CloudKit environment ('development' or 'production')
  cloudKitEnvironment: 'production',
};

// ============================================================================
// MAIN HANDLER
// ============================================================================

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);
    
    // Handle AASA file for Apple App Clip verification
    if (url.pathname === '/.well-known/apple-app-site-association') {
      return handleAASA();
    }
    
    // Handle card requests (both App Clip and vCard download)
    if (url.pathname === '/appclip' || url.pathname.startsWith('/card/')) {
      return handleCardRequest(request, url);
    }
    
    // Handle root - show a simple landing page
    if (url.pathname === '/') {
      return handleLandingPage();
    }
    
    return new Response('Not Found', { status: 404 });
  }
};

// ============================================================================
// AASA HANDLER - Apple App Clip Verification
// ============================================================================

function handleAASA() {
  const aasa = {
    appclips: {
      apps: [`${CONFIG.teamId}.${CONFIG.appClipBundleId}`]
    }
  };
  
  return new Response(JSON.stringify(aasa), {
    headers: {
      'Content-Type': 'application/json',
      'Cache-Control': 'public, max-age=86400', // Cache for 24 hours
    }
  });
}

// ============================================================================
// CARD REQUEST HANDLER
// ============================================================================

async function handleCardRequest(request, url) {
  // Extract record ID from URL
  // Supports: /appclip?id=xxx or /card/xxx
  let recordId = url.searchParams.get('id');
  if (!recordId && url.pathname.startsWith('/card/')) {
    recordId = url.pathname.split('/')[2];
  }
  
  if (!recordId) {
    return new Response('Missing card ID', { status: 400 });
  }
  
  // Check if this is an iOS device (App Clip will handle it natively)
  const userAgent = request.headers.get('User-Agent') || '';
  const isIOS = /iPhone|iPad|iPod/.test(userAgent);
  
  // For iOS, we could return a page that triggers App Clip,
  // but iOS typically intercepts the URL before reaching here.
  // This fallback is mainly for Android/desktop browsers.
  
  if (isIOS) {
    // Return a simple page that might help if App Clip doesn't auto-launch
    return handleIOSFallback(recordId);
  }
  
  // For non-iOS devices, fetch the vCard from CloudKit and serve it
  return handleVCardDownload(recordId);
}

// ============================================================================
// iOS FALLBACK - In case App Clip doesn't auto-launch
// ============================================================================

function handleIOSFallback(recordId) {
  const html = `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta name="apple-itunes-app" content="app-clip-bundle-id=${CONFIG.appClipBundleId}">
  <title>Opening BusinessCard...</title>
  <style>
    body {
      font-family: -apple-system, BlinkMacSystemFont, sans-serif;
      display: flex;
      justify-content: center;
      align-items: center;
      min-height: 100vh;
      margin: 0;
      background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
      color: white;
      text-align: center;
      padding: 20px;
      box-sizing: border-box;
    }
    .container { max-width: 400px; }
    h1 { font-size: 24px; margin-bottom: 16px; }
    p { font-size: 16px; opacity: 0.8; line-height: 1.5; }
    .loader {
      width: 40px;
      height: 40px;
      border: 3px solid rgba(255,255,255,0.3);
      border-top-color: white;
      border-radius: 50%;
      animation: spin 1s linear infinite;
      margin: 24px auto;
    }
    @keyframes spin { to { transform: rotate(360deg); } }
  </style>
</head>
<body>
  <div class="container">
    <div class="loader"></div>
    <h1>Opening BusinessCard</h1>
    <p>If the app doesn't open automatically, make sure you have iOS 14 or later.</p>
  </div>
</body>
</html>`;

  return new Response(html, {
    headers: { 'Content-Type': 'text/html; charset=utf-8' }
  });
}

// ============================================================================
// VCARD DOWNLOAD - For Android/Desktop browsers
// ============================================================================

async function handleVCardDownload(recordId) {
  try {
    // Fetch the record from CloudKit public database
    const vCardData = await fetchFromCloudKit(recordId);
    
    if (!vCardData) {
      return handleNotFound();
    }
    
    // Extract name from vCard for filename
    const nameMatch = vCardData.match(/FN:(.+)/);
    const name = nameMatch ? nameMatch[1].trim().replace(/[^a-zA-Z0-9]/g, '_') : 'contact';
    
    // Return as downloadable vCard file
    return new Response(vCardData, {
      headers: {
        'Content-Type': 'text/vcard; charset=utf-8',
        'Content-Disposition': `attachment; filename="${name}.vcf"`,
        'Cache-Control': 'private, max-age=300', // Cache for 5 minutes
      }
    });
    
  } catch (error) {
    console.error('Error fetching vCard:', error);
    return handleError();
  }
}

// ============================================================================
// CLOUDKIT FETCH
// ============================================================================

async function fetchFromCloudKit(recordName) {
  // CloudKit Web Services public database query
  // Note: For public database reads, no authentication is needed
  
  const ckURL = `https://api.apple-cloudkit.com/database/1/${CONFIG.cloudKitContainer}/${CONFIG.cloudKitEnvironment}/public/records/lookup`;
  
  const response = await fetch(ckURL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      records: [{ recordName }]
    })
  });
  
  if (!response.ok) {
    console.error('CloudKit error:', await response.text());
    return null;
  }
  
  const data = await response.json();
  
  if (!data.records || data.records.length === 0) {
    return null;
  }
  
  const record = data.records[0];
  
  // Check if expired
  if (record.fields?.expiresAt?.value) {
    const expiresAt = new Date(record.fields.expiresAt.value);
    if (expiresAt < new Date()) {
      return null; // Expired
    }
  }
  
  return record.fields?.vCardData?.value || null;
}

// ============================================================================
// ERROR PAGES
// ============================================================================

function handleNotFound() {
  const html = `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Card Not Found</title>
  <style>
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      display: flex;
      justify-content: center;
      align-items: center;
      min-height: 100vh;
      margin: 0;
      background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
      color: white;
      text-align: center;
      padding: 20px;
      box-sizing: border-box;
    }
    .container { max-width: 400px; }
    h1 { font-size: 48px; margin-bottom: 8px; }
    h2 { font-size: 24px; margin-bottom: 16px; font-weight: 500; }
    p { font-size: 16px; opacity: 0.8; line-height: 1.5; }
  </style>
</head>
<body>
  <div class="container">
    <h1>😕</h1>
    <h2>Card Not Found</h2>
    <p>This business card may have expired or been removed. Shared cards are available for 7 days.</p>
  </div>
</body>
</html>`;

  return new Response(html, {
    status: 404,
    headers: { 'Content-Type': 'text/html; charset=utf-8' }
  });
}

function handleError() {
  const html = `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Error</title>
  <style>
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      display: flex;
      justify-content: center;
      align-items: center;
      min-height: 100vh;
      margin: 0;
      background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
      color: white;
      text-align: center;
      padding: 20px;
      box-sizing: border-box;
    }
    .container { max-width: 400px; }
    h1 { font-size: 48px; margin-bottom: 8px; }
    h2 { font-size: 24px; margin-bottom: 16px; font-weight: 500; }
    p { font-size: 16px; opacity: 0.8; line-height: 1.5; }
  </style>
</head>
<body>
  <div class="container">
    <h1>⚠️</h1>
    <h2>Something Went Wrong</h2>
    <p>We couldn't load this business card. Please try again or ask the sender for a new link.</p>
  </div>
</body>
</html>`;

  return new Response(html, {
    status: 500,
    headers: { 'Content-Type': 'text/html; charset=utf-8' }
  });
}

// ============================================================================
// LANDING PAGE
// ============================================================================

function handleLandingPage() {
  const html = `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>BusinessCard</title>
  <style>
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      display: flex;
      justify-content: center;
      align-items: center;
      min-height: 100vh;
      margin: 0;
      background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
      color: white;
      text-align: center;
      padding: 20px;
      box-sizing: border-box;
    }
    .container { max-width: 400px; }
    h1 { font-size: 32px; margin-bottom: 16px; }
    p { font-size: 18px; opacity: 0.8; line-height: 1.5; }
  </style>
</head>
<body>
  <div class="container">
    <h1>📇 BusinessCard</h1>
    <p>Share your digital business card with anyone, even if they don't have the app.</p>
  </div>
</body>
</html>`;

  return new Response(html, {
    headers: { 'Content-Type': 'text/html; charset=utf-8' }
  });
}
```

3. Click **Save and Deploy**

### Step 3.3: Update Configuration Values

In the worker code, update these values at the top:

```javascript
const CONFIG = {
  teamId: '6R7KLBPBLZ',  // Your Team ID from Apple Developer Portal
  appClipBundleId: 'com.mbrucedogs.BusinessCard.Clip',  // From Base.xcconfig
  cloudKitContainer: 'iCloud.com.mbrucedogs.BusinessCard',  // From Base.xcconfig
  cloudKitEnvironment: 'production',  // or 'development' for testing
};
```

### Step 3.4: Add Custom Domain to Worker

1. In the Worker settings, click **Triggers** tab
2. Click **Add Custom Domain**
3. Enter your domain (e.g., `cards.yourdomain.com` or just `yourdomain.com`)
4. Cloudflare will automatically configure DNS

### Step 3.5: Verify AASA File

Open in browser: `https://yourdomain.com/.well-known/apple-app-site-association`

You should see:
```json
{"appclips":{"apps":["6R7KLBPBLZ.com.mbrucedogs.BusinessCard.Clip"]}}
```

---

## Phase 4: Configure App Store Connect (~20 minutes)

### Step 4.1: Register App Clip Bundle ID

1. Go to [Apple Developer Portal](https://developer.apple.com/account)
2. Click **Certificates, Identifiers & Profiles**
3. Click **Identifiers** → **+**
4. Select **App IDs** → **App Clip**
5. Enter:
   - Description: `BusinessCard App Clip`
   - Bundle ID: `com.mbrucedogs.BusinessCard.Clip`
6. Enable capabilities:
   - **Associated Domains** ✅
   - **iCloud** (CloudKit) ✅
7. Click **Continue** → **Register**

### Step 4.2: Create App Store Connect Record

1. Go to [App Store Connect](https://appstoreconnect.apple.com)
2. Select your BusinessCard app
3. Under **App Clip** section (or create if first time)
4. Configure the App Clip Experience:
   - **Title**: BusinessCard
   - **Subtitle**: View & save contact
   - **Action**: Open

### Step 4.3: Add Invocation URLs

1. In App Clip settings, click **Advanced App Clip Experiences**
2. Add your URL pattern:
   - URL: `https://yourdomain.com/appclip`
   - Action: Open

---

## Phase 5: Update Xcode Project (~10 minutes)

### Step 5.1: Update Base.xcconfig

Open `BusinessCard/Configuration/Base.xcconfig` and update:

```
APPCLIP_DOMAIN = yourdomain.com
```

Replace `yourdomain.com` with your actual domain.

### Step 5.2: Build and Upload to TestFlight

1. Select **BusinessCard** scheme (main app - it includes the App Clip)
2. Product → Archive
3. Distribute to App Store Connect
4. Upload to TestFlight

### Step 5.3: Test with TestFlight

1. Install the app via TestFlight on a real device
2. Open the app → Share tab
3. Generate an App Clip link for a card
4. Scan the QR code with another iOS device's camera
5. App Clip should launch and show the card preview

---

## Phase 6: Testing Checklist

### Local Testing (Simulator)

- [ ] Main app builds successfully
- [ ] App Clip target builds successfully
- [ ] Can generate App Clip URL in Share view
- [ ] CloudKit record is created

### TestFlight Testing

- [ ] App + App Clip installed via TestFlight
- [ ] QR code scans and App Clip launches
- [ ] Card preview shows correctly with photo
- [ ] "Save to Contacts" works
- [ ] Android device downloads .vcf file from same URL

### Production Readiness

- [ ] AASA file accessible at domain
- [ ] CloudKit production schema deployed
- [ ] App Clip Experience configured in App Store Connect

---

## Troubleshooting

### App Clip Doesn't Launch When Scanning QR

1. **Check AASA file**: Visit `https://yourdomain.com/.well-known/apple-app-site-association`
2. **Validate with Apple**: Use [AASA Validator](https://branch.io/resources/aasa-validator/)
3. **Check entitlements**: Ensure `appclips:yourdomain.com` is in both app and clip entitlements
4. **Wait for cache**: Apple caches AASA files; may take up to 24 hours

### CloudKit Fetch Fails

1. **Check environment**: Using `development` vs `production`?
2. **Check permissions**: Public database needs "World: Read" permission
3. **Check record exists**: Verify in CloudKit Console

### Android vCard Download Fails

1. **Check Cloudflare Worker logs**: Workers → your-worker → Logs
2. **Test CloudKit endpoint**: Try the fetch manually in worker
3. **Check CORS**: Shouldn't be needed for downloads but verify

---

## Summary

| Step | What | Time |
|------|------|------|
| 1 | Register domain | 10 min |
| 2 | CloudKit schema | 15 min |
| 3 | Cloudflare Worker | 30 min |
| 4 | App Store Connect | 20 min |
| 5 | Xcode + TestFlight | 10 min |
| 6 | Testing | 15 min |
| **Total** | | **~2 hours** |

**Ongoing Cost**: ~$10-15/year (domain only)

---

*Last updated: January 10, 2026*
