個人開発で週次リフレクションPWA「CoupleOps」を作っています。
最初の記事では環境構築まわり、次の記事では PWA 本体のざっくり設計を書きました。
- 0話:Vite + React + Cloudflare Pages で PWA の箱を作る
👉 https://qiita.com/arika1125/items/8aa295b30e0d657f1427 - 1話:PWA の骨組みと画面構成メモ
👉 https://qiita.com/arika1125/items/db8c466ce7327e769a73
今回はその続きとして、
- 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が持ってきてくれたもの
-
useEffectでdata-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 側の設定
- [広告] → [広告ユニットごと] → ディスプレイ広告
- 広告ユニット名:
coupleops-bottom-bannerなど - 広告サイズ:固定
- 幅:
320/ 高さ:100 - 保存して広告コードを確認
<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-formatやdata-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-slotとins.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エージェントを使いながら実装していく予定なので、
その辺りもまた記事にします。