0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AIエージェントと一緒に、個人開発PWAに「テーマ切替」と「下部固定AdSenseバナー」を入れた話

Posted at

個人開発で週次リフレクションPWA「CoupleOps」を作っています。

最初の記事では環境構築まわり、次の記事では PWA 本体のざっくり設計を書きました。

今回はその続きとして、

  • AIエージェント(IDEのAI機能)と一緒に実装した機能
  • Google AdSense の「下部固定バナー」を入れるまでにハマったポイント

をまとめておきます。

実際に触れるβ版はこちら(※内容は今後変わる可能性あり)
👉 https://coupleops-app.pages.dev


TL;DR

  • AIエージェントに仕様を投げて、テーマ切替 / 同意ゲート / 下部広告枠のひな型を一気に生成してもらった

  • ただしそのままだと

    • localhostで広告が出なかったり
    • スマホだけ巨大広告になったり
    • Auto ads と手動広告がケンカしたり
      ……という落とし穴が大量発生
  • 最終的には

    • 固定サイズ(320×100)のディスプレイ広告ユニット
    • <ins> にも同じサイズを指定
    • CSS は「位置と装飾だけ」にしてサイズはいじらない
      という形に落ち着いた

1. 技術スタックとAIエージェント

スタック

  • フロント:Vite + React + TypeScript
  • スタイル:素の CSS(ads.css など)
  • ホスティング:Cloudflare Pages
  • 開発環境:Windows + WSL2(Ubuntu) + VS Code
    + Google Antigravity(VS Code内AIエージェント)

AIエージェントにやってもらったこと

  • hook・コンポーネントの雛形生成
  • WSL / Cloudflare Pages 向けのコマンド補助
  • AdSense 周りの「よくあるコード」のリファレンス取得

人間(自分)がやったこと

  • 仕様を文章化して AI に投げる
  • 生成コードの取捨選択・リネーム・責務分割
  • 実際の挙動確認とデバッグ
  • AdSense ポリシー周りの確認

AIに雑に丸投げするのではなく、
「仕様の翻訳とリファクタリングを手伝ってもらう」感じ
で使うとちょうど良かったです。


2. 機能①:テーマ切替(ライト / ダーク / ターコイズ)

要件

  • ヘッダー右上の設定メニューからテーマを選択
  • html 要素の data-theme に反映して、CSS カスタムプロパティを切り替える
  • 選択したテーマは localStorage に保存
  • 画面再読み込みしても継続

AIに投げたプロンプト要約

  • React + TS + Vite
  • data-theme でテーマを切り替えたい
  • light/dark/turquoise の3テーマ
  • Settings画面から選択して localStorage に保存する hook を作って

返ってきた案をベースに、型と初期化ロジックを整えたのがこちらです。

// src/hooks/useTheme.ts

const STORAGE_KEY = "co_theme";

const THEMES = ["light", "dark", "turquoise"] as const;
export type Theme = (typeof THEMES)[number];

export function useTheme() {
  const [theme, setThemeState] = useState<Theme>(() => {
    if (typeof window === "undefined") return "light";
    const stored = window.localStorage.getItem(STORAGE_KEY) as Theme | null;
    return stored && THEMES.includes(stored) ? stored : "light";
  });

  useEffect(() => {
    document.documentElement.dataset.theme = theme;
    window.localStorage.setItem(STORAGE_KEY, theme);
  }, [theme]);

  return { theme, setTheme: (next: Theme) => setThemeState(next) };
}

AIが持ってきてくれたもの

  • useEffectdata-theme + localStorage を更新する流れ
  • localStorage 初期値を state 初期化関数に渡すパターン

手動で調整したところ

  • テーマ一覧を as const で型安全に持つ
  • window がない環境(SSR)のガード
  • data-theme 変更を document.documentElement に統一

3. 機能②:同意ゲート(ConsentGate)

要件

  • 初回起動時にモーダルを出す

    • 「同意して進む」「同意しないで進む」
  • 同意しない場合

    • GA / 広告は起動しない
    • アプリ自体は使える
  • 選択は localStorage に保存

コアロジック

// consent state の読み込み
type ConsentState = "unknown" | "granted" | "declined";

function readInitialConsent(): ConsentState {
  if (typeof window === "undefined") return "unknown";
  const stored = window.localStorage.getItem("ads_consent");
  return stored === "granted" || stored === "declined" ? stored : "unknown";
}
// App.tsx(抜粋)

const CONSENT_KEY = "ads_consent";

export default function App() {
  const [consent, setConsent] = useState<ConsentState>(() => readInitialConsent());
  const gateOpen = consent !== "granted";
  const manualAdsEnabled = import.meta.env.VITE_MANUAL_ADS === "1";
  const shouldRenderManualAd = manualAdsEnabled && consent === "granted";

  useEffect(() => {
    if (consent === "unknown") return;
    window.localStorage.setItem(CONSENT_KEY, consent);
  }, [consent]);

  return (
    <>
      <div className="app-shell" aria-hidden={gateOpen}>
        <AppRoutes />
      </div>

      {shouldRenderManualAd && <BottomAnchorAd />}

      <ConsentGate
        open={gateOpen}
        status={consent === "declined" ? "declined" : "unknown"}
        onAccept={() => setConsent("granted")}
        onDecline={() => setConsent("declined")}
      />
    </>
  );
}

AI に助けてもらったところ

  • enum 風の状態管理+ localStorage の基本形
  • モーダル表示中は aria-hidden で背面コンテンツを隠すアイデア

自分で足したところ

  • VITE_MANUAL_ADS フラグで「広告機能ごと ON/OFF」できるようにした
  • Consent の結果を AdSense の初期化タイミングと連動させた

4. 機能③:下部固定の AdSense バナー(最終形)

ここからが本題です。
PWA に「邪魔にならない形で下部バナーを1つだけ出したい」ので、
AIエージェントとペアプロしながら実装しました。

4-1. AdSense 側の設定

  1. [広告] → [広告ユニットごと] → ディスプレイ広告
  2. 広告ユニット名:coupleops-bottom-banner など
  3. 広告サイズ:固定
  4. 幅:320 / 高さ:100
  5. 保存して広告コードを確認

<ins> 部分はこんな形になります(IDは例):

<ins class="adsbygoogle"
     style="display:inline-block;width:320px;height:100px"
     data-ad-client="ca-pub-xxxxxxxxxxxxxxxx"
     data-ad-slot="1234567890"></ins>

この pub-...slot.env に書き出します。


4-2. .env と Cloudflare Pages の環境変数

VITE_ADS_PUB_ID=pub-xxxxxxxxxxxxxxxx    # ca-pub- の pub- 以降
VITE_ADS_SLOT=1234567890                # data-ad-slot の値
VITE_MANUAL_ADS=1                       # 底部バナー機能をON

# 互換用
VITE_PUBLISHER_ID=pub-xxxxxxxxxxxxxxxx
VITE_AD_SLOT_ID=1234567890
  • ローカルでは .env に直接
  • 本番(Cloudflare Pages)では Settings → Variables に同じ名前で登録

4-3. BottomAnchorAd コンポーネント(最終形)

// src/components/BottomAnchorAd.tsx

const PUBLISHER =
  import.meta.env.VITE_ADS_PUB_ID || import.meta.env.VITE_PUBLISHER_ID;
const SLOT =
  import.meta.env.VITE_ADS_SLOT || import.meta.env.VITE_AD_SLOT_ID;

declare global {
  interface Window {
    adsbygoogle?: unknown[];
  }
}

export default function BottomAnchorAd() {
  const hasConfig = Boolean(PUBLISHER && SLOT);

  useEffect(() => {
    if (!hasConfig || typeof window === "undefined") return;

    try {
      window.adsbygoogle = window.adsbygoogle || [];
      window.adsbygoogle.push({});
    } catch (error) {
      console.error("Failed to initialise AdSense slot", error);
    }
  }, [hasConfig]);

  if (!hasConfig) return null;

  return (
    <div
      className="anchor-ad-slot"
      role="complementary"
      aria-label="スポンサーリンク"
    >
      <ins
        className="adsbygoogle"
        style={{ display: "inline-block", width: "320px", height: "100px" }}
        data-ad-client={`ca-${PUBLISHER}`}
        data-ad-slot={SLOT}
      />
    </div>
  );
}

重要ポイント

  • ユニットを固定サイズにしたので、data-ad-formatdata-full-width-responsive は付けない
  • サイズ指定は <ins> の inline-style に集約
  • Consent OK のときだけ、このコンポーネントを描画する

4-4. レイアウト用 CSS

/* src/styles/ads.css */

/* 底部バナーぶんの余白を main に確保 */
main {
  padding-bottom: calc(80px + env(safe-area-inset-bottom));
}

/* 画面最下部に固定するラッパー */
.anchor-ad-slot {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 60;
  display: flex;
  justify-content: center;
  padding: 8px 12px calc(12px + env(safe-area-inset-bottom));
  pointer-events: none;
  background: linear-gradient(
    180deg,
    rgba(17, 24, 39, 0) 0%,
    rgba(17, 24, 39, 0.16) 100%
  );
}

/* 実際の広告枠 */
.anchor-ad-slot .adsbygoogle {
  /* サイズは inline-style (320x100) に任せる */
  pointer-events: auto;
  border-radius: 16px;
  overflow: hidden;
}

/* 対応ブラウザでは背景をぼかして少しだけ浮かせる */
@supports (backdrop-filter: blur(6px)) {
  .anchor-ad-slot {
    backdrop-filter: blur(6px);
  }
}

5. ハマりポイントまとめ

5-1. localhost で広告が出ない

  • http://localhost:5173 では、本物の広告が出ないことがかなり多い
  • DOM に anchor-ad-slotins.adsbygoogle がいて、
    data-ad-client / data-ad-slot が正しければ実装はOKと割り切る
  • 実際の表示確認は本番ドメイン(Cloudflare Pages)で行う

5-2. スマホだけ巨大広告になる

  • ユニットがレスポンシブのまま
  • <ins>data-full-width-responsive などレスポンシブ系属性
  • CSSで width:100%min-height を指定

このあたりが揃うと、スマホでカード型の大きい広告が選ばれやすく、
画面が広告に占領されているように見える

→ 固定ユニット + シンプルな <ins> + サイズを触らないCSS に変えて解決。

5-3. Auto ads と手動広告がケンカする

  • サイトの自動広告が ON のままだと、

    • <body> 直下に別の <ins class="adsbygoogle"> が差し込まれる
    • 上下に別のアンカー広告が出たりする
  • DevTools 上で、どれが自分のバナーか分からなくなる

→ 今回の PWA では Auto ads を OFF にして、
自前の底部バナー1枠だけ に統一した。

5-4. Consent を「同意しない」にしたままデバッグして詰む

  • ads_consent = "declined" の状態を忘れて、
    何度リロードしても広告が出ない
  • ブラウザのサイトデータ削除 or localStorage.removeItem("ads_consent") でリセット

6. AIエージェントを使ってみての所感

  • 「ゼロから自分で書くと 3〜4 時間かかるもの」が、
    30分〜1時間くらいで雛形まで到達できるのはかなり大きい

  • AdSense や PWA まわりのように、
    ドキュメントが分散している領域は、
    AI にサンプルを引いてきてもらうだけでもだいぶ楽

  • 一方で、

    • 仕様の最終判断
    • 依存関係や責務分割
    • 実際の挙動確認と微調整
      は最終的に人間がやる必要がある

体感としては「ロジック7割+調整3割」をAIに手伝ってもらうイメージでした。


7. さいごに:PWAを触ってフィードバックもらえると嬉しいです

ここまで書いてきた、

  • テーマ切替
  • 同意ゲート
  • 下部固定バナー

は、すべて実際の PWA 上で動いています。

β版 PWA「CoupleOps」はこちら:
👉 https://coupleops-app.pages.dev

  • PC ならブラウザからそのまま
  • iOS / Android なら「ホーム画面に追加」で PWA として利用できます。

「ここが使いにくい」「こんな機能が欲しい」などあれば、
Qiita のコメントや X などで教えてもらえるととても励みになります 🙌

今後は、週次リフレクション部分やKPI可視化なども
AIエージェントを使いながら実装していく予定なので、
その辺りもまた記事にします。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?