{
  "config": {
    "argv": [
      "/opt/hostedtoolcache/node/20.20.2/x64/bin/node",
      "/home/runner/work/dr-card-web/dr-card-web/node_modules/.bin/playwright",
      "test",
      "tests/e2e/test-gate.spec.ts"
    ],
    "configFile": "/home/runner/work/dr-card-web/dr-card-web/playwright.config.ts",
    "rootDir": "/home/runner/work/dr-card-web/dr-card-web/tests/e2e",
    "failOnFlakyTests": false,
    "forbidOnly": true,
    "fullyParallel": true,
    "globalSetup": null,
    "globalTeardown": null,
    "globalTimeout": 0,
    "grep": {},
    "grepInvert": null,
    "maxFailures": 0,
    "metadata": {
      "ci": {
        "commitHref": "https://github.com/sanchuang-dev/dr-card-web/commit/83115d2a353fe98d534425a2003d7c7a876d9357",
        "commitHash": "83115d2a353fe98d534425a2003d7c7a876d9357",
        "prHref": "https://github.com/sanchuang-dev/dr-card-web/pull/232",
        "prTitle": "chore(release): promote dev to test — full-screen project coupon redemption",
        "prBaseHash": "e248c28908e00c179d09f58a9fa27a48922a7332",
        "buildHref": "https://github.com/sanchuang-dev/dr-card-web/actions/runs/38553623705"
      },
      "gitCommit": {
        "shortHash": "83115d2",
        "hash": "83115d2a353fe98d534425a2003d7c7a876d9357",
        "subject": "Merge 5275a8dc86c9540347dd9a703edefd22e87895f4 into e248c28908e00c179d09f58a9fa27a48922a7332",
        "body": "Merge 5275a8dc86c9540347dd9a703edefd22e87895f4 into e248c28908e00c179d09f58a9fa27a48922a7332\n",
        "author": {
          "name": "Tomz Dang",
          "email": "dangjingtao@163.com",
          "time": 1791975669000
        },
        "committer": {
          "name": "GitHub",
          "email": "noreply@github.com",
          "time": 1791975669000
        },
        "branch": "HEAD"
      },
      "gitDiff": "diff --git a/scripts/verify-h014-mock-scenarios.mjs b/scripts/verify-h014-mock-scenarios.mjs\nindex e0227e1..578c778 100644\n--- a/scripts/verify-h014-mock-scenarios.mjs\n+++ b/scripts/verify-h014-mock-scenarios.mjs\n@@ -23,6 +23,7 @@ try {\n     acceptBuddyPhoneInvitation,\n   } = await vite.ssrLoadModule('/src/services/buddyPhone.ts')\n   const { redeemExchangeProduct } = await vite.ssrLoadModule('/src/services/exchange.ts')\n+  const { fetchCouponProjects } = await vite.ssrLoadModule('/src/services/couponProjects.ts')\n   const { AppError } = await vite.ssrLoadModule('/src/services/http/appError.ts')\n   const {\n     H014_BUDDY_PHONE_SCENARIOS,\n@@ -77,7 +78,13 @@ try {\n   assert.equal((await searchBuddyByPhone(H014_BUDDY_PHONE_SCENARIOS.incoming)).outcome, 'already-buddies')\n   assert.equal(await acceptBuddyPhoneInvitation(incoming.invitationId), 'already-buddies')\n \n-  assert.deepEqual(await redeemExchangeProduct(1), { couponId: 1, points: 1080 })\n+  const projects = await fetchCouponProjects('', 1)\n+  assert.equal(projects.per_page, 15)\n+  assert.equal(projects.data.length, 15)\n+  const matches = await fetchCouponProjects('南沙', 1)\n+  assert.ok(matches.data.length > 0)\n+  assert.ok(matches.data.every(project => project.shop_name.includes('南沙')))\n+  assert.deepEqual(await redeemExchangeProduct(1, projects.data[0].id), { couponId: 1, points: 1080 })\n \n   const buddyPageSource = fs.readFileSync('src/pages/BuddyPhoneInvite.tsx', 'utf8')\n   assert.doesNotMatch(buddyPageSource, /setTimeout|resolveBuddyPhoneOutcome|markPhoneInvited/)\ndiff --git a/src/mocks/handlers/exchange.ts b/src/mocks/handlers/exchange.ts\nindex 9046e87..499e90a 100644\n--- a/src/mocks/handlers/exchange.ts\n+++ b/src/mocks/handlers/exchange.ts\n@@ -1,35 +1,57 @@\n import { http, HttpResponse } from 'msw'\n-\n import { EXCHANGE_REDEEM_PATH } from '../../services/exchange'\n+import { COUPON_PROJECT_INDEX_PATH, COUPON_PROJECT_SEARCH_PATH } from '../../services/couponProjects'\n \n-async function readCouponId(request: Request) {\n-  const body: unknown = await request.json().catch(() => null)\n-  if (!body || typeof body !== 'object') return null\n-  const id = (body as { id?: unknown }).id\n-  return typeof id === 'number' && Number.isInteger(id) && id > 0 ? id : null\n+const names = [\n+  '深圳南山科技园', '深圳福田中心', '深圳宝安新安', '广州南沙蕉门', '广州南沙金洲',\n+  '广州南沙黄阁', '广州南沙横沥', '广州南沙万顷沙', '广州天河体育西', '广州天河珠江新城',\n+  '广州番禺市桥', '广州番禺大学城', '广州海珠客村', '广州白云嘉禾', '广州荔湾西关',\n+  '广州越秀北京路', '深圳龙华民治', '深圳罗湖东门', '深圳龙岗坂田', '深圳光明凤凰',\n+  '东莞南城', '东莞松山湖', '佛山禅城祖庙', '佛山南海桂城', '中山石岐',\n+  '珠海香洲', '惠州惠城', '江门蓬江', '肇庆端州', '韶关浈江', '韶关武江',\n+]\n+/** Clearly labeled PREVIEW data; never used when dataMode=api. */\n+const mockProjects = names.map((name, index) => ({\n+  id: 100 + index, shop_name: `Mock·${name}体验中心`,\n+  activity_name: 'Mock·洗护体验活动', discountcard_no: 'MOCK-CARD',\n+}))\n+const envelope = (data: unknown) => HttpResponse.json({ code: 0, msg: 'success', status: 'succ', data })\n+const pageResult = (keyword: string, page: number, pageSize: number) => {\n+  const filtered = mockProjects.filter(project => project.shop_name.includes(keyword))\n+  return {\n+    data: filtered.slice((page - 1) * pageSize, page * pageSize),\n+    current_page: page, per_page: pageSize,\n+    total: filtered.length, last_page: Math.max(1, Math.ceil(filtered.length / pageSize)),\n+  }\n }\n+const validPaging = (page: unknown, size: unknown) => ({\n+  page: Math.max(1, Number(page) || 1),\n+  size: Math.max(1, Math.min(15, Number(size) || 15)),\n+})\n \n export const exchangeHandlers = [\n+  http.get(`*${COUPON_PROJECT_INDEX_PATH}`, ({ request }) => {\n+    const params = new URL(request.url).searchParams\n+    const { page, size } = validPaging(params.get('page'), params.get('pageSize'))\n+    return envelope(pageResult('', page, size))\n+  }),\n+  http.post(`*${COUPON_PROJECT_SEARCH_PATH}`, async ({ request }) => {\n+    const body = await request.json().catch(() => null) as { shop_name?: unknown; page?: unknown; pageSize?: unknown } | null\n+    const term = typeof body?.shop_name === 'string' ? body.shop_name.trim() : ''\n+    if (!term) return HttpResponse.json({ code: 500, message: '\"卡博士项目名称\" is required', data: [] })\n+    const { page, size } = validPaging(body?.page, body?.pageSize)\n+    return envelope(pageResult(term, page, size))\n+  }),\n   http.post(`*${EXCHANGE_REDEEM_PATH}`, async ({ request }) => {\n-    const couponId = await readCouponId(request)\n-    if (couponId === null) {\n-      return HttpResponse.json({\n-        code: 500,\n-        message: '\"优惠券ID\" is required',\n-        data: [],\n-      })\n+    const body = await request.json().catch(() => null) as { id?: unknown; project_id?: unknown } | null\n+    const couponId = body?.id\n+    const projectId = body?.project_id\n+    if (typeof couponId !== 'number' || !Number.isInteger(couponId) || couponId <= 0) {\n+      return HttpResponse.json({ code: 500, message: '\"优惠券ID\" is required', data: [] })\n     }\n-\n-    // Preview/dev mock intentionally uses the exact production endpoint and envelope.\n-    // It only simulates the confirmed contract; it does not claim a real deduction/issue.\n-    return HttpResponse.json({\n-      code: 0,\n-      msg: 'success',\n-      status: 'succ',\n-      data: {\n-        coupon_id: couponId,\n-        points: 1080,\n-      },\n-    })\n+    if (typeof projectId !== 'number' || !Number.isInteger(projectId) || projectId <= 0) {\n+      return HttpResponse.json({ code: 500, message: '\"卡博士项目ID\" is required', data: [] })\n+    }\n+    return envelope({ coupon_id: couponId, points: 1080 })\n   }),\n ]\ndiff --git a/src/pages/Exchange.test.tsx b/src/pages/Exchange.test.tsx\nindex f71c6ea..cd0894e 100644\n--- a/src/pages/Exchange.test.tsx\n+++ b/src/pages/Exchange.test.tsx\n@@ -29,6 +29,13 @@ vi.mock('./exchange/useExchangeFeed', () => ({\n   useExchangeCoupons: mocks.useExchangeCoupons,\n   useExchangeCategories: () => ({ remote: mocks.categoryRemote, reload: mocks.categoryReload }),\n }))\n+vi.mock('./exchange/useCouponProjects', () => ({\n+  useCouponProjects: () => ({\n+    items: [{ id: 12, shop_name: 'Mock·广州南沙蕉门体验中心', activity_name: 'Mock·洗护活动', discountcard_no: 'DC1' }],\n+    page: 1, lastPage: 1, total: 1, loading: false, loadingMore: false, hasMore: false,\n+    error: null, loadMoreError: null, reload: vi.fn(), loadMore: vi.fn(),\n+  }),\n+}))\n vi.mock('./points/usePointsFeed', () => ({\n   useUserPointsStat: () => ({ remote: mocks.pointsRemote, reload: mocks.reloadPoints }),\n }))\n@@ -102,10 +109,15 @@ function successRemote() {\n   return { remote: { state: 'success', data: COUPONS }, reload: mocks.reloadCoupons }\n }\n \n+function chooseProject(dialog: HTMLElement) {\n+  fireEvent.click(within(dialog).getByRole('radio', { name: /Mock·广州南沙蕉门体验中心/ }))\n+  return within(dialog).getByRole('button', { name: '确认兑换' })\n+}\n+\n function openCoupon(id = 1) {\n   const p = COUPONS.find(x => x.id === id)!\n   fireEvent.click(screen.getByRole('button', { name: `${p.name}，${p.cost} 泡泡值` }))\n-  return screen.getByRole('dialog', { name: '确认兑换' })\n+  return screen.getByRole('dialog', { name: '选择适用片区' })\n }\n \n afterEach(() => {\n@@ -131,7 +143,7 @@ describe('UX-E: page-local exchange flow', () => {\n     expect(screen.getAllByText('200', { exact: false }).length).toBeGreaterThan(0)\n   })\n \n-  it('keeps every category switch, sheet open/close and selected coupon on /exchange with no query/hash', () => {\n+  it('keeps every category switch, full-screen close and selected coupon on /exchange with no query/hash', () => {\n     mocks.useExchangeCoupons.mockReturnValue(successRemote())\n     renderExchange()\n     fireEvent.click(screen.getByRole('tab', { name: '洗发体验' }))\n@@ -140,11 +152,11 @@ describe('UX-E: page-local exchange flow', () => {\n \n     openCoupon()\n     expect(screen.getByTestId('route').textContent).toBe('/exchange')\n-    fireEvent.pointerDown(screen.getByRole('dialog', { name: '确认兑换' }).parentElement!)\n+    fireEvent.click(screen.getByRole('button', { name: '返回兑换专区' }))\n     expect(screen.getByTestId('route').textContent).toBe('/exchange')\n \n     fireEvent.click(screen.getByRole('tab', { name: '护发体验' }))\n-    expect(screen.queryByRole('dialog', { name: '确认兑换' })).toBeNull()\n+    expect(screen.queryByRole('dialog', { name: '选择适用片区' })).toBeNull()\n     expect(screen.getByTestId('route').textContent).toBe('/exchange')\n \n     openCoupon(3)\n@@ -216,10 +228,10 @@ describe('UX-E: page-local exchange flow', () => {\n     mocks.useExchangeCoupons.mockReturnValue(successRemote())\n     renderExchange()\n     let sheet = openCoupon(2)\n-    expect((within(sheet).getByRole('button', { name: '已兑完' }) as HTMLButtonElement).disabled).toBe(true)\n-    fireEvent.pointerDown(sheet.parentElement!)\n+    expect((chooseProject(sheet) as HTMLButtonElement).disabled).toBe(true)\n+    fireEvent.click(within(sheet).getByRole('button', { name: '返回兑换专区' }))\n     sheet = openCoupon(3)\n-    expect((within(sheet).getByRole('button', { name: '泡泡值不足' }) as HTMLButtonElement).disabled).toBe(true)\n+    expect((chooseProject(sheet) as HTMLButtonElement).disabled).toBe(true)\n     expect(mocks.redeem).not.toHaveBeenCalled()\n   })\n \n@@ -228,7 +240,7 @@ describe('UX-E: page-local exchange flow', () => {\n     mocks.useExchangeCoupons.mockReturnValue(successRemote())\n     renderExchange()\n     const sheet = openCoupon()\n-    expect((within(sheet).getByRole('button', { name: '立即兑换' }) as HTMLButtonElement).disabled).toBe(true)\n+    expect((chooseProject(sheet) as HTMLButtonElement).disabled).toBe(true)\n     expect(mocks.redeem).not.toHaveBeenCalled()\n   })\n \n@@ -239,11 +251,11 @@ describe('UX-E: page-local exchange flow', () => {\n       .mockResolvedValueOnce({ couponId: 1, points: 300 })\n     renderExchange()\n     let sheet = openCoupon()\n-    fireEvent.click(within(sheet).getByRole('button', { name: '立即兑换' }))\n+    fireEvent.click(chooseProject(sheet))\n     await waitFor(() => expect(within(sheet).getByRole('alert').textContent).toContain('后端拒绝兑换'))\n     expect(screen.getByTestId('route').textContent).toBe('/exchange')\n \n-    fireEvent.click(within(sheet).getByRole('button', { name: '立即兑换' }))\n+    fireEvent.click(chooseProject(sheet))\n     await waitFor(() => expect(screen.getByRole('dialog', { name: '模拟兑换结果' })).toBeTruthy())\n     expect(mocks.redeem).toHaveBeenCalledTimes(2)\n     expect(screen.getByTestId('route').textContent).toBe('/exchange')\n@@ -257,7 +269,7 @@ describe('UX-E: page-local exchange flow', () => {\n     }))\n     renderExchange()\n     const sheet = openCoupon()\n-    const submit = within(sheet).getByRole('button', { name: '立即兑换' })\n+    const submit = chooseProject(sheet)\n     fireEvent.click(submit)\n     fireEvent.click(submit)\n     expect(mocks.redeem).toHaveBeenCalledTimes(1)\n@@ -272,7 +284,7 @@ describe('UX-E: page-local exchange flow', () => {\n     mocks.redeem.mockResolvedValue({ couponId: 1, points: 300 })\n     renderExchange()\n     const sheet = openCoupon()\n-    fireEvent.click(within(sheet).getByRole('button', { name: '立即兑换' }))\n+    fireEvent.click(chooseProject(sheet))\n     const result = await screen.findByRole('dialog', { name: '模拟兑换结果' })\n     expect(within(result).getByText('当前为 Mock 预览，仅验证正式兑换协议与页面状态，不代表真实扣值或发券。')).toBeTruthy()\n     expect(mocks.reloadPoints).toHaveBeenCalledOnce()\n@@ -282,7 +294,7 @@ describe('UX-E: page-local exchange flow', () => {\n     expect(screen.queryByRole('dialog')).toBeNull()\n     expect(screen.getByTestId('route').textContent).toBe('/exchange')\n     openCoupon()\n-    fireEvent.click(screen.getByRole('dialog', { name: '确认兑换' }).querySelector('button:last-child')!)\n+    fireEvent.click(chooseProject(screen.getByRole('dialog', { name: '选择适用片区' })))\n     const nextResult = await screen.findByRole('dialog', { name: '模拟兑换结果' })\n     fireEvent.click(within(nextResult).getByRole('button', { name: '查看我的卡包' }))\n     expect(screen.getByTestId('route').textContent).toBe('/card')\n@@ -296,13 +308,13 @@ describe('UX-E: page-local exchange flow', () => {\n     renderExchange()\n \n     const sheet = openCoupon()\n-    const submit = within(sheet).getByRole('button', { name: '立即兑换' }) as HTMLButtonElement\n+    const submit = chooseProject(sheet) as HTMLButtonElement\n     expect(submit.disabled).toBe(false)\n     fireEvent.click(submit)\n \n     const result = await screen.findByRole('dialog', { name: '兑换结果' })\n     expect(within(result).getByText('兑换完成，剩余泡泡值 300。可前往卡包查看新券。')).toBeTruthy()\n-    expect(mocks.redeem).toHaveBeenCalledWith(1)\n+    expect(mocks.redeem).toHaveBeenCalledWith(1, 12)\n     expect(mocks.reloadPoints).toHaveBeenCalledOnce()\n     expect(mocks.reloadCoupons).toHaveBeenCalledOnce()\n     expect(screen.getByTestId('route').textContent).toBe('/exchange')\ndiff --git a/src/pages/Exchange.tsx b/src/pages/Exchange.tsx\nindex fb7ed5b..95d7796 100644\n--- a/src/pages/Exchange.tsx\n+++ b/src/pages/Exchange.tsx\n@@ -6,7 +6,7 @@ import EmptyStateIllustration from '../components/mobile/EmptyStateIllustration'\n import PageContainer from '../components/mobile/PageContainer'\n import PromptOverlay from '../components/mobile/PromptOverlay'\n import BubbleValueRedeemCard from '../components/card/BubbleValueRedeemCard'\n-import { BottomSheet, Button, EmptyState, SegmentedControl } from '../components/ui'\n+import { Button, EmptyState, SegmentedControl } from '../components/ui'\n import { findRouteByPathname } from '../app/router/routes'\n import { runtimePolicy } from '../app/config/runtime'\n import { useUserPointsStat } from './points/usePointsFeed'\n@@ -15,6 +15,7 @@ import { EXCHANGE_COPY } from '../app/fixtures'\n import type { CouponRedeemView } from '../services/coupons'\n import { redeemExchangeProduct, type ExchangeRedeemResult } from '../services/exchange'\n import kitThumb from '../assets/brand/member/checkin-dearseed-kit.webp'\n+import ProjectSelectScreen from './exchange/ProjectSelectScreen'\n \n /** 接口 `image` 为空时的券图兜底（沿用已验收品牌图，不新增假字段）。 */\n const COUPON_COVER_FALLBACK = kitThumb\n@@ -49,7 +50,7 @@ const availabilityButtonLabel = (state: ExchangeAvailability): string =>\n  * 接口来源：`GET /api/coupons/index`（7002「优惠券管理」，券列表接口，用户 2026-09-29 提供图二）。\n  * 已确认：顶部「我的泡泡值 + 立即兑换」余额条、后台动态分类 Tab、两列体验券卡\n  *        （券图 / 名称 / 所需泡泡值 / 兑换量 / 已兑完遮罩 / 兑换按钮）、\n- *        点击卡片打开兑换弹窗（券图名 / x1 / 说明 / 泡泡值 / 立即兑换）。\n+ *        点击券卡打开全屏适用片区选择（券摘要 / 搜索分页 / 单选 / 确认兑换）。\n  *\n  * 数据口径（2026-09-29 与产品确认）：\n  * - 当前真实业务只开放「通用体验包」兑换；多分类 / 多券页面结构保留，作为后续恢复多体验券时的扩展位，\n@@ -143,14 +144,14 @@ export default function Exchange() {\n     couponListRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })\n   }\n \n-  const submit = async () => {\n+  const submit = async (projectId: number) => {\n     if (!activeProduct || availability !== 'redeemable' || submissionLock.current) return\n \n     submissionLock.current = true\n     setSubmitting(true)\n     setSubmitError(null)\n     try {\n-      const result = await redeemExchangeProduct(activeProduct.id)\n+      const result = await redeemExchangeProduct(activeProduct.id, projectId)\n       setActiveProductId(null)\n       setRedeemResult(result)\n \n@@ -301,66 +302,19 @@ export default function Exchange() {\n         <ChevronRight className=\"h-4 w-4 shrink-0 text-text-tertiary\" aria-hidden />\n       </button>\n \n-      {/* #39 体验券兑换弹窗（原型 §3） */}\n-      <BottomSheet open={activeProduct !== null} title=\"确认兑换\" onClose={closeRedeem}>\n-        {activeProduct && (\n-          <>\n-            <div className=\"flex gap-3\">\n-              <span className=\"relative block h-20 w-20 shrink-0 overflow-hidden rounded-coupon bg-surface-subtle\">\n-                {activeProduct.image || COUPON_COVER_FALLBACK ? (\n-                  <img\n-                    src={activeProduct.image || COUPON_COVER_FALLBACK}\n-                    alt=\"\"\n-                    className=\"h-full w-full object-cover\"\n-                  />\n-                ) : (\n-                  <span className=\"flex h-full w-full items-center justify-center text-text-tertiary\" aria-hidden>\n-                    <Ticket className=\"h-7 w-7\" />\n-                  </span>\n-                )}\n-              </span>\n-              <div className=\"min-w-0 flex-1\">\n-                <div className=\"flex items-start gap-2\">\n-                  <p className=\"min-w-0 flex-1 text-sm font-medium text-text-primary\">{activeProduct.name}</p>\n-                  <span className=\"shrink-0 text-sm text-text-secondary\">{EXCHANGE_COPY.quantity}</span>\n-                </div>\n-                <p className=\"mt-1 text-xs leading-5 text-text-secondary\">{activeProduct.desc}</p>\n-                <p className=\"mt-2 text-base font-semibold text-exchange-price-text\">\n-                  {activeProduct.cost}\n-                  <span className=\"ml-0.5 text-xs font-normal\">🫧 {EXCHANGE_COPY.costUnit}</span>\n-                </p>\n-              </div>\n-            </div>\n-\n-            <div className=\"mt-3 flex items-center justify-between rounded-control bg-surface-subtle px-3 py-2 text-xs\">\n-              <span className=\"text-text-secondary\">{EXCHANGE_COPY.balanceLabel}</span>\n-              <span className=\"font-medium text-text-primary\">{balance == null ? '--' : `${balance.toLocaleString()} 🫧`}</span>\n-            </div>\n-\n-            {(availability === 'sold-out' || availability === 'insufficient') && (\n-              <p className=\"mt-2 text-xs text-danger-text\" role=\"status\">\n-                {availability === 'sold-out' ? EXCHANGE_COPY.soldOut : EXCHANGE_COPY.insufficient}\n-              </p>\n-            )}\n-\n-            {submitError && (\n-              <p className=\"mt-2 text-xs text-danger-text\" role=\"alert\">\n-                {submitError}\n-              </p>\n-            )}\n-\n-            <Button\n-              size=\"large\"\n-              className=\"mt-4 w-full rounded-full\"\n-              loading={submitting}\n-              disabled={availability !== 'redeemable' || submitting}\n-              onClick={() => void submit()}\n-            >\n-              {submitting ? EXCHANGE_COPY.submitting : availabilityButtonLabel(availability)}\n-            </Button>\n-          </>\n-        )}\n-      </BottomSheet>\n+      {/* Approved 2026-10-14 full-screen in-page step: no URL or nested BottomSheet. */}\n+      {activeProduct && (\n+        <ProjectSelectScreen\n+          key={activeProduct.id}\n+          coupon={activeProduct}\n+          fallbackImage={COUPON_COVER_FALLBACK}\n+          balance={balance}\n+          submitting={submitting}\n+          submitError={submitError}\n+          onClose={closeRedeem}\n+          onConfirm={(projectId) => { void submit(projectId) }}\n+        />\n+      )}\n \n       <PromptOverlay\n         open={redeemResult !== null}\ndiff --git a/src/pages/exchange/ProjectSelectScreen.tsx b/src/pages/exchange/ProjectSelectScreen.tsx\nnew file mode 100644\nindex 0000000..4e91488\n--- /dev/null\n+++ b/src/pages/exchange/ProjectSelectScreen.tsx\n@@ -0,0 +1,267 @@\n+import { useEffect, useMemo, useRef, useState, type UIEvent } from 'react'\n+import { createPortal } from 'react-dom'\n+import { Check, ChevronLeft, Search, X } from 'lucide-react'\n+import type { CouponRedeemView } from '../../services/coupons'\n+import type { CouponProject } from '../../services/couponProjects'\n+import { useCouponProjects } from './useCouponProjects'\n+\n+interface Props {\n+  coupon: CouponRedeemView\n+  fallbackImage: string\n+  balance: number | null\n+  submitting: boolean\n+  submitError: string | null\n+  onClose: () => void\n+  onConfirm: (projectId: number) => void\n+}\n+\n+function ProjectOption({ project, checked, onChoose }: {\n+  project: CouponProject\n+  checked: boolean\n+  onChoose: () => void\n+}) {\n+  return (\n+    <button\n+      type=\"button\"\n+      role=\"radio\"\n+      aria-checked={checked}\n+      onClick={onChoose}\n+      className={`flex min-h-[76px] w-full items-center gap-3 rounded-[13px] border px-3 py-3 text-left shadow-[0_3px_16px_rgba(70,51,30,.035)] transition active:scale-[.993] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[#DF4800] ${\n+        checked ? 'border-[#EBA879] bg-[#FFF8F2]' : 'border-[#F1EAE2] bg-white'\n+      }`}\n+    >\n+      <span className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-[1.5px] ${\n+        checked ? 'border-[#DF4800] bg-[#DF4800] text-white' : 'border-[#CCC4B9] bg-white'\n+      }`}>\n+        {checked && <Check size={13} strokeWidth={2.5} aria-hidden />}\n+      </span>\n+      <span className=\"min-w-0 flex-1\">\n+        <span className=\"block break-words text-[14px] font-semibold leading-[21px] text-[#28231E]\">{project.shop_name}</span>\n+        <span className=\"mt-[5px] block truncate text-[11px] leading-[17px] text-[#999086]\">{project.activity_name}</span>\n+      </span>\n+      {checked && <span className=\"shrink-0 rounded-full bg-[#FFEDE0] px-2 py-1 text-[10px] text-[#BF672C]\">已选择</span>}\n+    </button>\n+  )\n+}\n+\n+/** Full-screen, in-page exchange step. Selection survives every search and page change. */\n+export default function ProjectSelectScreen({\n+  coupon, fallbackImage, balance, submitting, submitError, onClose, onConfirm,\n+}: Props) {\n+  const [input, setInput] = useState('')\n+  const [query, setQuery] = useState('')\n+  const [selected, setSelected] = useState<CouponProject | null>(null)\n+  const scroller = useRef<HTMLElement>(null)\n+  const dialogRef = useRef<HTMLDivElement>(null)\n+  const { items, total, page, loading, loadingMore, hasMore, error, loadMoreError, restartRequired, reload, loadMore } =\n+    useCouponProjects(query)\n+\n+  useEffect(() => {\n+    const timeout = window.setTimeout(() => setQuery(input.trim()), 300)\n+    return () => window.clearTimeout(timeout)\n+  }, [input])\n+\n+  useEffect(() => { if (scroller.current) scroller.current.scrollTop = 0 }, [query])\n+\n+  // An in-page portal must own keyboard focus just like the shared Dialog.\n+  // Focus the back action (not the search input) to avoid opening the mobile keyboard.\n+  useEffect(() => {\n+    const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null\n+    const dialog = dialogRef.current\n+    const first = dialog?.querySelector<HTMLButtonElement>('button:not(:disabled)')\n+    first?.focus({ preventScroll: true })\n+    const keepFocusInside = (event: FocusEvent) => {\n+      if (dialog && event.target instanceof Node && !dialog.contains(event.target)) {\n+        first?.focus({ preventScroll: true })\n+      }\n+    }\n+    document.addEventListener('focusin', keepFocusInside)\n+    return () => {\n+      document.removeEventListener('focusin', keepFocusInside)\n+      previous?.focus({ preventScroll: true })\n+    }\n+  }, [])\n+\n+\n+  useEffect(() => {\n+    const onKeyDown = (event: KeyboardEvent) => {\n+      if (event.key === 'Escape' && !submitting) onClose()\n+      if (event.key === 'Tab') {\n+        const elements = [...(dialogRef.current?.querySelectorAll<HTMLElement>(\n+          'button:not(:disabled), input:not(:disabled), [tabindex]:not([tabindex=\"-1\"])',\n+        ) ?? [])]\n+        if (!elements.length) return\n+        const first = elements[0]\n+        const last = elements[elements.length - 1]\n+        if (event.shiftKey && document.activeElement === first) {\n+          event.preventDefault()\n+          last.focus()\n+        } else if (!event.shiftKey && document.activeElement === last) {\n+          event.preventDefault()\n+          first.focus()\n+        }\n+      }\n+    }\n+    window.addEventListener('keydown', onKeyDown)\n+    return () => window.removeEventListener('keydown', onKeyDown)\n+  }, [onClose, submitting])\n+\n+  const otherItems = useMemo(\n+    () => items.filter(item => item.id !== selected?.id),\n+    [items, selected?.id],\n+  )\n+\n+  const onScroll = (event: UIEvent<HTMLElement>) => {\n+    const target = event.currentTarget\n+    if (target.scrollHeight - target.scrollTop - target.clientHeight < 160) loadMore()\n+  }\n+\n+  return createPortal(\n+    <div\n+      ref={dialogRef}\n+      role=\"dialog\"\n+      aria-modal=\"true\"\n+      aria-label=\"选择适用片区\"\n+      className=\"fixed inset-0 z-[150] flex flex-col bg-[#FFFBF5] text-[#28231E]\"\n+      style={{ height: '100dvh' }}\n+    >\n+      <header className=\"shrink-0 pt-[env(safe-area-inset-top)]\">\n+        <div className=\"grid h-12 grid-cols-[44px_minmax(0,1fr)_44px] items-center px-4\">\n+          <button\n+            type=\"button\"\n+            disabled={submitting}\n+            onClick={onClose}\n+            aria-label=\"返回兑换专区\"\n+            className=\"flex h-10 w-10 items-center justify-center rounded-full active:bg-[#F7EEE3] disabled:opacity-50\"\n+          >\n+            <ChevronLeft size={23} strokeWidth={1.9} aria-hidden />\n+          </button>\n+          <h1 className=\"text-center text-[16px] font-semibold\">选择适用片区</h1>\n+        </div>\n+\n+        <section className=\"mx-4 mt-[7px] rounded-[15px] border border-[#F2E9DF] bg-white px-[13px] pb-3 pt-[13px] shadow-[0_3px_16px_rgba(70,51,30,.035)]\" aria-label=\"待兑换券详情\">\n+          <div className=\"mb-[10px] flex items-center justify-between text-[11px] text-[#99826B]\">\n+            <span>待兑换体验券</span><span className=\"font-medium text-[#7B756F]\">×1</span>\n+          </div>\n+          <div className=\"flex items-center gap-3\">\n+            <img src={coupon.image || fallbackImage} alt=\"\" className=\"h-[67px] w-[67px] shrink-0 rounded-[10px] border border-[#F3EFE7] object-cover\" />\n+            <div className=\"min-w-0 flex-1\">\n+              <p className=\"truncate text-[15px] font-semibold leading-5\">{coupon.name}</p>\n+              <p className=\"mt-1 truncate text-[11px] leading-[17px] text-[#8B8379]\">{coupon.desc}</p>\n+              <p className=\"mt-[7px] text-[11px] font-medium text-[#996B30]\">\n+                <span className=\"mr-1 text-[17px] font-bold text-[#DF4800]\">{coupon.cost}</span>泡泡值\n+              </p>\n+            </div>\n+          </div>\n+        </section>\n+\n+        <div className=\"px-4 pb-[10px] pt-[15px]\">\n+          <div className=\"flex h-[46px] items-center gap-[10px] rounded-full border border-[#EEE6DC] bg-white px-[15px] focus-within:border-[#ED9C69]\">\n+            <Search size={19} className=\"shrink-0 text-[#AAA095]\" aria-hidden />\n+            <input\n+              type=\"search\"\n+              aria-label=\"搜索片区或项目名称\"\n+              placeholder=\"搜索片区或项目名称\"\n+              enterKeyHint=\"search\"\n+              autoComplete=\"off\"\n+              spellCheck={false}\n+              value={input}\n+              onChange={event => setInput(event.target.value)}\n+              className=\"h-full min-w-0 flex-1 bg-transparent text-[14px] outline-none placeholder:text-[#AFA79F] [&::-webkit-search-cancel-button]:hidden\"\n+            />\n+            {input && (\n+              <button type=\"button\" onClick={() => setInput('')} aria-label=\"清空搜索\"\n+                className=\"flex h-[25px] w-[25px] shrink-0 items-center justify-center rounded-full bg-[#F3F0EB] text-[#978D84]\">\n+                <X size={14} aria-hidden />\n+              </button>\n+            )}\n+          </div>\n+          <div className=\"flex items-center justify-between px-[2px] pt-3 text-[11px] text-[#A3998E]\">\n+            <span>单选一个适用项目</span><span>每次加载 15 条</span>\n+          </div>\n+        </div>\n+      </header>\n+\n+      <section\n+        ref={scroller}\n+        onScroll={onScroll}\n+        aria-label=\"片区搜索结果\"\n+        className=\"min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain px-4 pb-4\"\n+      >\n+        <div role=\"radiogroup\" aria-label=\"选择一个适用片区\">\n+          {selected && (\n+            <section className=\"mb-[15px]\" aria-label=\"已选片区\">\n+              <p className=\"mb-[9px] mt-1 flex items-center gap-[6px] px-[2px] text-[12px] font-semibold text-[#AE662E]\">\n+                <Check size={14} className=\"text-[#DF4800]\" aria-hidden />已选片区 · 搜索后仍保留\n+              </p>\n+              <ProjectOption project={selected} checked onChoose={() => setSelected(selected)} />\n+            </section>\n+          )}\n+          <div className=\"mb-[6px] flex min-h-[29px] items-center justify-between px-[2px] text-[12px]\">\n+            <strong className=\"font-semibold\">搜索结果</strong>\n+            <span className=\"text-[11px] text-[#9B9389]\">{loading ? '正在搜索…' : `共 ${total} 条`}</span>\n+          </div>\n+          {loading && page === 0 ? (\n+            <div role=\"status\" aria-label=\"正在加载项目\" className=\"space-y-[9px]\">\n+              {[0, 1, 2].map(n => <div key={n} className=\"h-[76px] animate-pulse rounded-[13px] border border-[#F3EDE5] bg-[#FBF5EC]\" />)}\n+            </div>\n+          ) : error ? (\n+            <div role=\"alert\" className=\"py-10 text-center text-[13px] text-[#716B64]\">\n+              <p>片区加载失败：{error}</p>\n+              <button type=\"button\" onClick={reload} className=\"mt-3 rounded-full border border-[#EBA879] px-5 py-2 text-[#DF4800]\">重试</button>\n+            </div>\n+          ) : otherItems.length === 0 ? (\n+            <div className=\"py-10 text-center\">\n+              <Search size={23} className=\"mx-auto text-[#B7A48E]\" aria-hidden />\n+              <p className=\"mt-3 text-[14px] text-[#605950]\">没有找到相关片区</p>\n+              <p className=\"mt-[6px] text-[12px] leading-5 text-[#AEA49A]\">\n+                换个名称再试试{selected ? '，已选片区仍可继续兑换' : ''}\n+              </p>\n+            </div>\n+          ) : (\n+            <div className=\"flex flex-col gap-[9px]\">\n+              {otherItems.map(item => (\n+                <ProjectOption key={item.id} project={item} checked={false} onChoose={() => setSelected(item)} />\n+              ))}\n+            </div>\n+          )}\n+        </div>\n+        {loadingMore && <p role=\"status\" className=\"py-4 text-center text-[12px] text-[#AAA299]\">正在加载下一页…</p>}\n+        {loadMoreError && <div role=\"alert\" className=\"py-4 text-center text-[12px] text-[#716B64]\">{loadMoreError}\n+          <button type=\"button\" onClick={restartRequired ? reload : loadMore} className=\"ml-2 text-[#DF4800]\">重试</button>\n+        </div>}\n+        {hasMore && !loadingMore && !loadMoreError && (\n+          <button type=\"button\" onClick={loadMore} className=\"w-full py-4 text-center text-[12px] text-[#AA6C42]\">\n+            继续加载下一页\n+          </button>\n+        )}\n+        {!loading && !error && page > 0 && !hasMore &&\n+          <p className=\"py-4 text-center text-[11px] text-[#AAA299]\">已加载全部 {total} 条项目</p>}\n+      </section>\n+\n+      <footer className=\"shrink-0 border-t border-[#F1E9DF] bg-[#FFFBF5] px-4 pb-[max(16px,env(safe-area-inset-bottom))] pt-3\">\n+        {submitError && <p role=\"alert\" className=\"mb-2 text-[12px] text-danger-text\">{submitError}</p>}\n+        {coupon.soldOut && <p role=\"status\" className=\"mb-2 text-[12px] text-danger-text\">体验券已兑完</p>}\n+        {!coupon.soldOut && balance !== null && balance < coupon.cost && (\n+          <p role=\"status\" className=\"mb-2 text-[12px] text-danger-text\">泡泡值不足，暂时无法兑换</p>\n+        )}\n+        {!coupon.soldOut && balance === null && (\n+          <p role=\"status\" className=\"mb-2 text-[12px] text-danger-text\">当前余额不可用，请稍后重试</p>\n+        )}\n+        <div className=\"mb-3 flex items-baseline justify-between gap-2 text-[12px] text-[#93867A]\">\n+          <span>泡泡值余额 <b className=\"text-[#38302A]\">{balance?.toLocaleString() ?? '--'}</b></span>\n+          <span>本次消耗 <b className=\"text-[14px] text-[#DF4800]\">{coupon.cost}</b> 泡泡值</span>\n+        </div>\n+        <button\n+          type=\"button\"\n+          disabled={!selected || submitting || balance === null || balance < coupon.cost || coupon.soldOut}\n+          onClick={() => { if (selected) onConfirm(selected.id) }}\n+          className=\"h-[49px] w-full rounded-full bg-[#DF4800] text-[15px] font-semibold text-white active:bg-[#C83D00] disabled:bg-[#DCCFC1]\"\n+        >\n+          {submitting ? '兑换中…' : selected ? '确认兑换' : '请选择适用片区'}\n+        </button>\n+      </footer>\n+    </div>,\n+    document.body,\n+  )\n+}\ndiff --git a/src/pages/exchange/useCouponProjects.test.tsx b/src/pages/exchange/useCouponProjects.test.tsx\nnew file mode 100644\nindex 0000000..49bfdfe\n--- /dev/null\n+++ b/src/pages/exchange/useCouponProjects.test.tsx\n@@ -0,0 +1,50 @@\n+import { act, renderHook, waitFor } from '@testing-library/react'\n+import { afterEach, describe, expect, it, vi } from 'vitest'\n+\n+const mocks = vi.hoisted(() => ({ fetch: vi.fn() }))\n+vi.mock('../../services/couponProjects', () => ({ fetchCouponProjects: mocks.fetch }))\n+\n+import { useCouponProjects } from './useCouponProjects'\n+\n+const row = (id: number) => ({\n+  id, shop_name: 'Mock·片区' + id, activity_name: 'Mock·体验券', discountcard_no: 'DC' + id,\n+})\n+const page = (n: number, total: number, ids: number[], lastPage = 2) => ({\n+  data: ids.map(row), current_page: n, per_page: 15, total, last_page: lastPage,\n+})\n+\n+afterEach(() => mocks.fetch.mockReset())\n+\n+describe('project pagination search generation', () => {\n+  it('restores page one after a changed server catalog instead of infinitely retrying stale page two', async () => {\n+    mocks.fetch.mockResolvedValueOnce(page(1, 16, [1, 2]))\n+      .mockResolvedValueOnce(page(2, 17, [3]))\n+      .mockResolvedValueOnce(page(1, 17, [1, 2, 3]))\n+    const hook = renderHook(() => useCouponProjects(''))\n+    await waitFor(() => expect(hook.result.current.page).toBe(1))\n+\n+    act(() => hook.result.current.loadMore())\n+    await waitFor(() => expect(hook.result.current.restartRequired).toBe(true))\n+    expect(hook.result.current.items.map(p => p.id)).toEqual([1, 2])\n+\n+    act(() => hook.result.current.reload())\n+    await waitFor(() => expect(hook.result.current.total).toBe(17))\n+    expect(hook.result.current.restartRequired).toBe(false)\n+    expect(mocks.fetch.mock.calls.map(call => call[1])).toEqual([1, 2, 1])\n+    hook.unmount()\n+  })\n+\n+  it('ignores an old search response after a new keyword is entered', async () => {\n+    let completeOld!: (value: ReturnType<typeof page>) => void\n+    mocks.fetch.mockImplementationOnce(() => new Promise(resolve => { completeOld = resolve }))\n+      .mockResolvedValueOnce(page(1, 1, [30], 1))\n+    const hook = renderHook(({ keyword }) => useCouponProjects(keyword), {\n+      initialProps: { keyword: '南沙' },\n+    })\n+    hook.rerender({ keyword: '天河' })\n+    await waitFor(() => expect(hook.result.current.items.map(p => p.id)).toEqual([30]))\n+    await act(async () => completeOld(page(1, 1, [10], 1)))\n+    expect(hook.result.current.items.map(p => p.id)).toEqual([30])\n+    hook.unmount()\n+  })\n+})\ndiff --git a/src/pages/exchange/useCouponProjects.ts b/src/pages/exchange/useCouponProjects.ts\nnew file mode 100644\nindex 0000000..678e7a7\n--- /dev/null\n+++ b/src/pages/exchange/useCouponProjects.ts\n@@ -0,0 +1,83 @@\n+import { useCallback, useEffect, useRef, useState } from 'react'\n+import { fetchCouponProjects, type CouponProject } from '../../services/couponProjects'\n+\n+interface ProjectQueryState {\n+  items: CouponProject[]\n+  page: number\n+  lastPage: number\n+  total: number\n+  loading: boolean\n+  loadingMore: boolean\n+  error: string | null\n+  loadMoreError: string | null\n+  restartRequired: boolean\n+}\n+\n+const initial: ProjectQueryState = {\n+  items: [], page: 0, lastPage: 1, total: 0,\n+  loading: true, loadingMore: false, error: null, loadMoreError: null, restartRequired: false,\n+}\n+const errorText = (error: unknown) => error instanceof Error ? error.message : '片区加载失败，请重试'\n+\n+/** Server pagination per query. Older search responses must never override newer results. */\n+export function useCouponProjects(keyword: string) {\n+  const [retry, setRetry] = useState(0)\n+  const [state, setState] = useState<ProjectQueryState>(initial)\n+  const generation = useRef(0)\n+  const morePending = useRef(false)\n+  const query = keyword.trim()\n+\n+  useEffect(() => {\n+    const current = ++generation.current\n+    morePending.current = false\n+    setState({ ...initial })\n+    void fetchCouponProjects(query, 1).then(\n+      result => {\n+        if (generation.current !== current) return\n+        setState({\n+          items: result.data, page: result.current_page, lastPage: result.last_page,\n+          total: result.total, loading: false, loadingMore: false,\n+          error: null, loadMoreError: null, restartRequired: false,\n+        })\n+      },\n+      error => {\n+        if (generation.current !== current) return\n+        setState({ ...initial, loading: false, error: errorText(error) })\n+      },\n+    )\n+    return () => { generation.current += 1 }\n+  }, [query, retry])\n+\n+  const reload = useCallback(() => setRetry(value => value + 1), [])\n+  const hasMore = !state.loading && !state.error && state.page > 0 && state.page < state.lastPage\n+  const loadMore = useCallback(() => {\n+    if (!hasMore || state.loadingMore || state.restartRequired || morePending.current) return\n+    morePending.current = true\n+    const current = generation.current\n+    const next = state.page + 1\n+    setState(prev => ({ ...prev, loadingMore: true, loadMoreError: null }))\n+    void fetchCouponProjects(query, next).then(\n+      result => {\n+        if (generation.current !== current) return\n+        setState(prev => {\n+          // Never append a changed catalog as if it were a consistent page.\n+          if (result.total !== prev.total || result.last_page !== prev.lastPage) {\n+            return { ...prev, loadingMore: false, loadMoreError: '列表已变化，请重新加载', restartRequired: true }\n+          }\n+          const rows = new Map(prev.items.map(item => [item.id, item]))\n+          result.data.forEach(item => rows.set(item.id, item))\n+          return { ...prev, items: [...rows.values()], page: result.current_page,\n+            loadingMore: false, loadMoreError: null, restartRequired: false }\n+        })\n+        morePending.current = false\n+      },\n+      error => {\n+        if (generation.current !== current) return\n+        morePending.current = false\n+        setState(prev => ({ ...prev, loadingMore: false, loadMoreError: errorText(error) }))\n+      },\n+    )\n+  }, [hasMore, query, state.loadingMore, state.page])\n+\n+  return { ...state, hasMore, reload, loadMore }\n+}\ndiff --git a/src/services/couponProjects.test.ts b/src/services/couponProjects.test.ts\nnew file mode 100644\nindex 0000000..29dd25b\n--- /dev/null\n+++ b/src/services/couponProjects.test.ts\n@@ -0,0 +1,40 @@\n+import { afterEach, describe, expect, it, vi } from 'vitest'\n+\n+const mocks = vi.hoisted(() => ({ request: vi.fn() }))\n+vi.mock('./http', () => ({\n+  httpClient: { request: mocks.request },\n+  createBusinessError: (message: string) => new Error(message),\n+}))\n+import { fetchCouponProjects, COUPON_PROJECT_INDEX_PATH, COUPON_PROJECT_SEARCH_PATH } from './couponProjects'\n+\n+const response = (page: number) => ({\n+  code: 0, data: {\n+    data: [{ id: 31, shop_name: '测试片区', activity_name: '体验券活动', discountcard_no: 'DC1' }],\n+    current_page: page, per_page: 15, total: 16, last_page: 2,\n+  },\n+})\n+afterEach(() => mocks.request.mockReset())\n+\n+describe('coupon project search protocol', () => {\n+  it('uses unfiltered GET index for the initial 15; no invalid blank search', async () => {\n+    mocks.request.mockResolvedValue(response(1))\n+    await expect(fetchCouponProjects('')).resolves.toMatchObject({ total: 16 })\n+    expect(mocks.request).toHaveBeenCalledWith({\n+      method: 'GET', url: COUPON_PROJECT_INDEX_PATH, params: { page: 1, pageSize: 15 },\n+    })\n+  })\n+  it('searches server-side with the required nonblank shop_name and paginates by 15', async () => {\n+    mocks.request.mockResolvedValue(response(2))\n+    await fetchCouponProjects('  南沙  ', 2)\n+    expect(mocks.request).toHaveBeenCalledWith({\n+      method: 'POST', url: COUPON_PROJECT_SEARCH_PATH,\n+      data: { shop_name: '南沙', page: 2, pageSize: 15 },\n+    })\n+  })\n+  it('rejects failed or malformed pages rather than exposing a false empty list', async () => {\n+    mocks.request.mockResolvedValueOnce({ code: 500, message: '查询失败', data: [] })\n+      .mockResolvedValueOnce({ code: 0, data: { ...response(1).data, current_page: 3 } })\n+    await expect(fetchCouponProjects('南沙')).rejects.toThrow('查询失败')\n+    await expect(fetchCouponProjects('南沙')).rejects.toThrow('分页数据异常')\n+  })\n+})\ndiff --git a/src/services/couponProjects.ts b/src/services/couponProjects.ts\nnew file mode 100644\nindex 0000000..2f18478\n--- /dev/null\n+++ b/src/services/couponProjects.ts\n@@ -0,0 +1,49 @@\n+import { z } from 'zod'\n+import { parseApiEnvelope } from './contracts/apiEnvelope'\n+import { httpClient } from './http'\n+\n+export const COUPON_PROJECT_INDEX_PATH = '/api/couponskbs/index'\n+export const COUPON_PROJECT_SEARCH_PATH = '/api/coupons/getProjects'\n+export const COUPON_PROJECT_PAGE_SIZE = 15\n+\n+const projectSchema = z.object({\n+  id: z.number().int().positive(),\n+  shop_name: z.string(),\n+  activity_name: z.string(),\n+  discountcard_no: z.string(),\n+}).passthrough()\n+\n+const pageSchema = z.object({\n+  data: z.array(projectSchema),\n+  current_page: z.number().int().positive(),\n+  per_page: z.number().int().positive(),\n+  total: z.number().int().nonnegative(),\n+  last_page: z.number().int().positive(),\n+}).passthrough()\n+\n+export type CouponProject = z.infer<typeof projectSchema>\n+export type CouponProjectsPage = z.infer<typeof pageSchema>\n+\n+/**\n+ * Blank keyword uses the public project index's documented page/pageSize DTO.\n+ * /coupons/getProjects requires a nonempty shop_name, so never send it an empty\n+ * search or fabricate a wildcard. Both endpoints expose the same project shape.\n+ */\n+export async function fetchCouponProjects(keyword: string, page = 1): Promise<CouponProjectsPage> {\n+  if (!Number.isInteger(page) || page < 1) throw new Error('项目页码无效')\n+  const term = keyword.trim()\n+  const payload = await httpClient.request<unknown>(term\n+    ? { method: 'POST', url: COUPON_PROJECT_SEARCH_PATH,\n+        data: { shop_name: term, page, pageSize: COUPON_PROJECT_PAGE_SIZE } }\n+    : { method: 'GET', url: COUPON_PROJECT_INDEX_PATH,\n+        params: { page, pageSize: COUPON_PROJECT_PAGE_SIZE } })\n+\n+  const result = parseApiEnvelope(payload, pageSchema, {\n+    contract: 'coupons.projects', fallbackMessage: '适用片区加载失败',\n+  })\n+  if (result.current_page !== page || result.per_page > COUPON_PROJECT_PAGE_SIZE ||\n+      result.data.length > COUPON_PROJECT_PAGE_SIZE) {\n+    throw new Error('片区列表分页数据异常，请重试')\n+  }\n+  return result\n+}\ndiff --git a/src/services/exchange.test.ts b/src/services/exchange.test.ts\nindex d559d90..cd3a80a 100644\n--- a/src/services/exchange.test.ts\n+++ b/src/services/exchange.test.ts\n@@ -23,14 +23,14 @@ describe('coupon exchange backend contract', () => {\n       data: { coupon_id: 3, points: 28 },\n     })\n \n-    await expect(redeemExchangeProduct(3)).resolves.toEqual({\n+    await expect(redeemExchangeProduct(3, 12)).resolves.toEqual({\n       couponId: 3,\n       points: 28,\n     })\n     expect(mocks.request).toHaveBeenCalledWith({\n       method: 'POST',\n       url: EXCHANGE_REDEEM_PATH,\n-      data: { id: 3 },\n+      data: { id: 3, project_id: 12 },\n     })\n   })\n \n@@ -46,7 +46,7 @@ describe('coupon exchange backend contract', () => {\n       data: [],\n     })\n \n-    await expect(redeemExchangeProduct(3)).rejects.toThrow(message)\n+    await expect(redeemExchangeProduct(3, 12)).rejects.toThrow(message)\n   })\n \n   it('rejects malformed successful data instead of showing a false success', async () => {\n@@ -57,6 +57,6 @@ describe('coupon exchange backend contract', () => {\n       data: { coupon_id: 3 },\n     })\n \n-    await expect(redeemExchangeProduct(3)).rejects.toThrow('coupons.exchange.data')\n+    await expect(redeemExchangeProduct(3, 12)).rejects.toThrow('coupons.exchange.data')\n   })\n })\ndiff --git a/src/services/exchange.ts b/src/services/exchange.ts\nindex 71652f0..ee1c015 100644\n--- a/src/services/exchange.ts\n+++ b/src/services/exchange.ts\n@@ -26,11 +26,12 @@ export interface ExchangeRedeemResult {\n  * both from the authenticated session and owns stock, deduction, ledger and issue\n  * compensation semantics.\n  */\n-export async function redeemExchangeProduct(productId: number): Promise<ExchangeRedeemResult> {\n+export async function redeemExchangeProduct(productId: number, projectId: number): Promise<ExchangeRedeemResult> {\n+  if (!Number.isInteger(projectId) || projectId <= 0) throw new Error('请选择适用片区')\n   const payload = await httpClient.request<unknown>({\n     method: 'POST',\n     url: EXCHANGE_REDEEM_PATH,\n-    data: { id: productId },\n+    data: { id: productId, project_id: projectId },\n   })\n \n   const data = parseApiEnvelope(payload, exchangeRedeemDataSchema, {\ndiff --git a/tests/e2e/uxe-exchange.spec.ts b/tests/e2e/uxe-exchange.spec.ts\nindex 686e2cb..8a12d46 100644\n--- a/tests/e2e/uxe-exchange.spec.ts\n+++ b/tests/e2e/uxe-exchange.spec.ts\n@@ -1,6 +1,6 @@\n import { expect, test } from '@playwright/test'\n \n-test('@formal-h5 UX-E exchange tabs and confirmation remain within one URL', async ({ page }) => {\n+test('@formal-h5 UX-E exchange tabs and full-screen project selection remain within one URL', async ({ page }) => {\n   await page.goto('/exchange', { waitUntil: 'domcontentloaded' })\n   const urlBefore = new URL(page.url())\n   expect(urlBefore.pathname).toBe('/exchange')\n@@ -16,11 +16,24 @@ test('@formal-h5 UX-E exchange tabs and confirmation remain within one URL', asy\n   const firstCoupon = list.locator('button').first()\n   await expect(firstCoupon).toBeVisible()\n   await firstCoupon.click()\n-  await expect(page.getByRole('dialog', { name: '确认兑换' })).toBeVisible()\n+  const selector = page.getByRole('dialog', { name: '选择适用片区' })\n+  await expect(selector).toBeVisible()\n   expect(new URL(page.url()).pathname + new URL(page.url()).search).toBe('/exchange')\n+  const chosen = selector.getByRole('radio', { name: /Mock·深圳南山科技园/ })\n+  await expect(chosen).toBeVisible()\n+  await chosen.click()\n+  await expect(selector.getByRole('region', { name: '已选片区' })).toContainText('深圳南山科技园')\n \n+  // Server search is unrelated to the chosen item: the selection must persist.\n+  await selector.getByRole('searchbox', { name: '搜索片区或项目名称' }).fill('天河')\n+  await expect(selector.getByRole('radio', { name: /Mock·广州天河体育西/ })).toBeVisible()\n+  await expect(selector.getByRole('region', { name: '已选片区' })).toContainText('深圳南山科技园')\n+  // The stable preview fixture has 128 points while the coupon costs 200:\n+  // preserve genuine insufficiency instead of mutating the fixture to force success.\n+  await expect(selector.getByText('泡泡值不足，暂时无法兑换')).toBeVisible()\n+  await expect(selector.getByRole('button', { name: '确认兑换' })).toBeDisabled()\n   await page.keyboard.press('Escape')\n-  await expect(page.getByRole('dialog', { name: '确认兑换' })).toHaveCount(0)\n+  await expect(selector).toHaveCount(0)\n   expect(new URL(page.url()).pathname + new URL(page.url()).search).toBe('/exchange')\n })",
      "actualWorkers": 2
    },
    "preserveOutput": "always",
    "projects": [
      {
        "outputDir": "/home/runner/work/dr-card-web/dr-card-web/test-results/artifacts",
        "repeatEach": 1,
        "retries": 1,
        "metadata": {
          "ci": {
            "commitHref": "https://github.com/sanchuang-dev/dr-card-web/commit/83115d2a353fe98d534425a2003d7c7a876d9357",
            "commitHash": "83115d2a353fe98d534425a2003d7c7a876d9357",
            "prHref": "https://github.com/sanchuang-dev/dr-card-web/pull/232",
            "prTitle": "chore(release): promote dev to test — full-screen project coupon redemption",
            "prBaseHash": "e248c28908e00c179d09f58a9fa27a48922a7332",
            "buildHref": "https://github.com/sanchuang-dev/dr-card-web/actions/runs/38553623705"
          },
          "gitCommit": {
            "shortHash": "83115d2",
            "hash": "83115d2a353fe98d534425a2003d7c7a876d9357",
            "subject": "Merge 5275a8dc86c9540347dd9a703edefd22e87895f4 into e248c28908e00c179d09f58a9fa27a48922a7332",
            "body": "Merge 5275a8dc86c9540347dd9a703edefd22e87895f4 into e248c28908e00c179d09f58a9fa27a48922a7332\n",
            "author": {
              "name": "Tomz Dang",
              "email": "dangjingtao@163.com",
              "time": 1791975669000
            },
            "committer": {
              "name": "GitHub",
              "email": "noreply@github.com",
              "time": 1791975669000
            },
            "branch": "HEAD"
          },
          "gitDiff": "diff --git a/scripts/verify-h014-mock-scenarios.mjs b/scripts/verify-h014-mock-scenarios.mjs\nindex e0227e1..578c778 100644\n--- a/scripts/verify-h014-mock-scenarios.mjs\n+++ b/scripts/verify-h014-mock-scenarios.mjs\n@@ -23,6 +23,7 @@ try {\n     acceptBuddyPhoneInvitation,\n   } = await vite.ssrLoadModule('/src/services/buddyPhone.ts')\n   const { redeemExchangeProduct } = await vite.ssrLoadModule('/src/services/exchange.ts')\n+  const { fetchCouponProjects } = await vite.ssrLoadModule('/src/services/couponProjects.ts')\n   const { AppError } = await vite.ssrLoadModule('/src/services/http/appError.ts')\n   const {\n     H014_BUDDY_PHONE_SCENARIOS,\n@@ -77,7 +78,13 @@ try {\n   assert.equal((await searchBuddyByPhone(H014_BUDDY_PHONE_SCENARIOS.incoming)).outcome, 'already-buddies')\n   assert.equal(await acceptBuddyPhoneInvitation(incoming.invitationId), 'already-buddies')\n \n-  assert.deepEqual(await redeemExchangeProduct(1), { couponId: 1, points: 1080 })\n+  const projects = await fetchCouponProjects('', 1)\n+  assert.equal(projects.per_page, 15)\n+  assert.equal(projects.data.length, 15)\n+  const matches = await fetchCouponProjects('南沙', 1)\n+  assert.ok(matches.data.length > 0)\n+  assert.ok(matches.data.every(project => project.shop_name.includes('南沙')))\n+  assert.deepEqual(await redeemExchangeProduct(1, projects.data[0].id), { couponId: 1, points: 1080 })\n \n   const buddyPageSource = fs.readFileSync('src/pages/BuddyPhoneInvite.tsx', 'utf8')\n   assert.doesNotMatch(buddyPageSource, /setTimeout|resolveBuddyPhoneOutcome|markPhoneInvited/)\ndiff --git a/src/mocks/handlers/exchange.ts b/src/mocks/handlers/exchange.ts\nindex 9046e87..499e90a 100644\n--- a/src/mocks/handlers/exchange.ts\n+++ b/src/mocks/handlers/exchange.ts\n@@ -1,35 +1,57 @@\n import { http, HttpResponse } from 'msw'\n-\n import { EXCHANGE_REDEEM_PATH } from '../../services/exchange'\n+import { COUPON_PROJECT_INDEX_PATH, COUPON_PROJECT_SEARCH_PATH } from '../../services/couponProjects'\n \n-async function readCouponId(request: Request) {\n-  const body: unknown = await request.json().catch(() => null)\n-  if (!body || typeof body !== 'object') return null\n-  const id = (body as { id?: unknown }).id\n-  return typeof id === 'number' && Number.isInteger(id) && id > 0 ? id : null\n+const names = [\n+  '深圳南山科技园', '深圳福田中心', '深圳宝安新安', '广州南沙蕉门', '广州南沙金洲',\n+  '广州南沙黄阁', '广州南沙横沥', '广州南沙万顷沙', '广州天河体育西', '广州天河珠江新城',\n+  '广州番禺市桥', '广州番禺大学城', '广州海珠客村', '广州白云嘉禾', '广州荔湾西关',\n+  '广州越秀北京路', '深圳龙华民治', '深圳罗湖东门', '深圳龙岗坂田', '深圳光明凤凰',\n+  '东莞南城', '东莞松山湖', '佛山禅城祖庙', '佛山南海桂城', '中山石岐',\n+  '珠海香洲', '惠州惠城', '江门蓬江', '肇庆端州', '韶关浈江', '韶关武江',\n+]\n+/** Clearly labeled PREVIEW data; never used when dataMode=api. */\n+const mockProjects = names.map((name, index) => ({\n+  id: 100 + index, shop_name: `Mock·${name}体验中心`,\n+  activity_name: 'Mock·洗护体验活动', discountcard_no: 'MOCK-CARD',\n+}))\n+const envelope = (data: unknown) => HttpResponse.json({ code: 0, msg: 'success', status: 'succ', data })\n+const pageResult = (keyword: string, page: number, pageSize: number) => {\n+  const filtered = mockProjects.filter(project => project.shop_name.includes(keyword))\n+  return {\n+    data: filtered.slice((page - 1) * pageSize, page * pageSize),\n+    current_page: page, per_page: pageSize,\n+    total: filtered.length, last_page: Math.max(1, Math.ceil(filtered.length / pageSize)),\n+  }\n }\n+const validPaging = (page: unknown, size: unknown) => ({\n+  page: Math.max(1, Number(page) || 1),\n+  size: Math.max(1, Math.min(15, Number(size) || 15)),\n+})\n \n export const exchangeHandlers = [\n+  http.get(`*${COUPON_PROJECT_INDEX_PATH}`, ({ request }) => {\n+    const params = new URL(request.url).searchParams\n+    const { page, size } = validPaging(params.get('page'), params.get('pageSize'))\n+    return envelope(pageResult('', page, size))\n+  }),\n+  http.post(`*${COUPON_PROJECT_SEARCH_PATH}`, async ({ request }) => {\n+    const body = await request.json().catch(() => null) as { shop_name?: unknown; page?: unknown; pageSize?: unknown } | null\n+    const term = typeof body?.shop_name === 'string' ? body.shop_name.trim() : ''\n+    if (!term) return HttpResponse.json({ code: 500, message: '\"卡博士项目名称\" is required', data: [] })\n+    const { page, size } = validPaging(body?.page, body?.pageSize)\n+    return envelope(pageResult(term, page, size))\n+  }),\n   http.post(`*${EXCHANGE_REDEEM_PATH}`, async ({ request }) => {\n-    const couponId = await readCouponId(request)\n-    if (couponId === null) {\n-      return HttpResponse.json({\n-        code: 500,\n-        message: '\"优惠券ID\" is required',\n-        data: [],\n-      })\n+    const body = await request.json().catch(() => null) as { id?: unknown; project_id?: unknown } | null\n+    const couponId = body?.id\n+    const projectId = body?.project_id\n+    if (typeof couponId !== 'number' || !Number.isInteger(couponId) || couponId <= 0) {\n+      return HttpResponse.json({ code: 500, message: '\"优惠券ID\" is required', data: [] })\n     }\n-\n-    // Preview/dev mock intentionally uses the exact production endpoint and envelope.\n-    // It only simulates the confirmed contract; it does not claim a real deduction/issue.\n-    return HttpResponse.json({\n-      code: 0,\n-      msg: 'success',\n-      status: 'succ',\n-      data: {\n-        coupon_id: couponId,\n-        points: 1080,\n-      },\n-    })\n+    if (typeof projectId !== 'number' || !Number.isInteger(projectId) || projectId <= 0) {\n+      return HttpResponse.json({ code: 500, message: '\"卡博士项目ID\" is required', data: [] })\n+    }\n+    return envelope({ coupon_id: couponId, points: 1080 })\n   }),\n ]\ndiff --git a/src/pages/Exchange.test.tsx b/src/pages/Exchange.test.tsx\nindex f71c6ea..cd0894e 100644\n--- a/src/pages/Exchange.test.tsx\n+++ b/src/pages/Exchange.test.tsx\n@@ -29,6 +29,13 @@ vi.mock('./exchange/useExchangeFeed', () => ({\n   useExchangeCoupons: mocks.useExchangeCoupons,\n   useExchangeCategories: () => ({ remote: mocks.categoryRemote, reload: mocks.categoryReload }),\n }))\n+vi.mock('./exchange/useCouponProjects', () => ({\n+  useCouponProjects: () => ({\n+    items: [{ id: 12, shop_name: 'Mock·广州南沙蕉门体验中心', activity_name: 'Mock·洗护活动', discountcard_no: 'DC1' }],\n+    page: 1, lastPage: 1, total: 1, loading: false, loadingMore: false, hasMore: false,\n+    error: null, loadMoreError: null, reload: vi.fn(), loadMore: vi.fn(),\n+  }),\n+}))\n vi.mock('./points/usePointsFeed', () => ({\n   useUserPointsStat: () => ({ remote: mocks.pointsRemote, reload: mocks.reloadPoints }),\n }))\n@@ -102,10 +109,15 @@ function successRemote() {\n   return { remote: { state: 'success', data: COUPONS }, reload: mocks.reloadCoupons }\n }\n \n+function chooseProject(dialog: HTMLElement) {\n+  fireEvent.click(within(dialog).getByRole('radio', { name: /Mock·广州南沙蕉门体验中心/ }))\n+  return within(dialog).getByRole('button', { name: '确认兑换' })\n+}\n+\n function openCoupon(id = 1) {\n   const p = COUPONS.find(x => x.id === id)!\n   fireEvent.click(screen.getByRole('button', { name: `${p.name}，${p.cost} 泡泡值` }))\n-  return screen.getByRole('dialog', { name: '确认兑换' })\n+  return screen.getByRole('dialog', { name: '选择适用片区' })\n }\n \n afterEach(() => {\n@@ -131,7 +143,7 @@ describe('UX-E: page-local exchange flow', () => {\n     expect(screen.getAllByText('200', { exact: false }).length).toBeGreaterThan(0)\n   })\n \n-  it('keeps every category switch, sheet open/close and selected coupon on /exchange with no query/hash', () => {\n+  it('keeps every category switch, full-screen close and selected coupon on /exchange with no query/hash', () => {\n     mocks.useExchangeCoupons.mockReturnValue(successRemote())\n     renderExchange()\n     fireEvent.click(screen.getByRole('tab', { name: '洗发体验' }))\n@@ -140,11 +152,11 @@ describe('UX-E: page-local exchange flow', () => {\n \n     openCoupon()\n     expect(screen.getByTestId('route').textContent).toBe('/exchange')\n-    fireEvent.pointerDown(screen.getByRole('dialog', { name: '确认兑换' }).parentElement!)\n+    fireEvent.click(screen.getByRole('button', { name: '返回兑换专区' }))\n     expect(screen.getByTestId('route').textContent).toBe('/exchange')\n \n     fireEvent.click(screen.getByRole('tab', { name: '护发体验' }))\n-    expect(screen.queryByRole('dialog', { name: '确认兑换' })).toBeNull()\n+    expect(screen.queryByRole('dialog', { name: '选择适用片区' })).toBeNull()\n     expect(screen.getByTestId('route').textContent).toBe('/exchange')\n \n     openCoupon(3)\n@@ -216,10 +228,10 @@ describe('UX-E: page-local exchange flow', () => {\n     mocks.useExchangeCoupons.mockReturnValue(successRemote())\n     renderExchange()\n     let sheet = openCoupon(2)\n-    expect((within(sheet).getByRole('button', { name: '已兑完' }) as HTMLButtonElement).disabled).toBe(true)\n-    fireEvent.pointerDown(sheet.parentElement!)\n+    expect((chooseProject(sheet) as HTMLButtonElement).disabled).toBe(true)\n+    fireEvent.click(within(sheet).getByRole('button', { name: '返回兑换专区' }))\n     sheet = openCoupon(3)\n-    expect((within(sheet).getByRole('button', { name: '泡泡值不足' }) as HTMLButtonElement).disabled).toBe(true)\n+    expect((chooseProject(sheet) as HTMLButtonElement).disabled).toBe(true)\n     expect(mocks.redeem).not.toHaveBeenCalled()\n   })\n \n@@ -228,7 +240,7 @@ describe('UX-E: page-local exchange flow', () => {\n     mocks.useExchangeCoupons.mockReturnValue(successRemote())\n     renderExchange()\n     const sheet = openCoupon()\n-    expect((within(sheet).getByRole('button', { name: '立即兑换' }) as HTMLButtonElement).disabled).toBe(true)\n+    expect((chooseProject(sheet) as HTMLButtonElement).disabled).toBe(true)\n     expect(mocks.redeem).not.toHaveBeenCalled()\n   })\n \n@@ -239,11 +251,11 @@ describe('UX-E: page-local exchange flow', () => {\n       .mockResolvedValueOnce({ couponId: 1, points: 300 })\n     renderExchange()\n     let sheet = openCoupon()\n-    fireEvent.click(within(sheet).getByRole('button', { name: '立即兑换' }))\n+    fireEvent.click(chooseProject(sheet))\n     await waitFor(() => expect(within(sheet).getByRole('alert').textContent).toContain('后端拒绝兑换'))\n     expect(screen.getByTestId('route').textContent).toBe('/exchange')\n \n-    fireEvent.click(within(sheet).getByRole('button', { name: '立即兑换' }))\n+    fireEvent.click(chooseProject(sheet))\n     await waitFor(() => expect(screen.getByRole('dialog', { name: '模拟兑换结果' })).toBeTruthy())\n     expect(mocks.redeem).toHaveBeenCalledTimes(2)\n     expect(screen.getByTestId('route').textContent).toBe('/exchange')\n@@ -257,7 +269,7 @@ describe('UX-E: page-local exchange flow', () => {\n     }))\n     renderExchange()\n     const sheet = openCoupon()\n-    const submit = within(sheet).getByRole('button', { name: '立即兑换' })\n+    const submit = chooseProject(sheet)\n     fireEvent.click(submit)\n     fireEvent.click(submit)\n     expect(mocks.redeem).toHaveBeenCalledTimes(1)\n@@ -272,7 +284,7 @@ describe('UX-E: page-local exchange flow', () => {\n     mocks.redeem.mockResolvedValue({ couponId: 1, points: 300 })\n     renderExchange()\n     const sheet = openCoupon()\n-    fireEvent.click(within(sheet).getByRole('button', { name: '立即兑换' }))\n+    fireEvent.click(chooseProject(sheet))\n     const result = await screen.findByRole('dialog', { name: '模拟兑换结果' })\n     expect(within(result).getByText('当前为 Mock 预览，仅验证正式兑换协议与页面状态，不代表真实扣值或发券。')).toBeTruthy()\n     expect(mocks.reloadPoints).toHaveBeenCalledOnce()\n@@ -282,7 +294,7 @@ describe('UX-E: page-local exchange flow', () => {\n     expect(screen.queryByRole('dialog')).toBeNull()\n     expect(screen.getByTestId('route').textContent).toBe('/exchange')\n     openCoupon()\n-    fireEvent.click(screen.getByRole('dialog', { name: '确认兑换' }).querySelector('button:last-child')!)\n+    fireEvent.click(chooseProject(screen.getByRole('dialog', { name: '选择适用片区' })))\n     const nextResult = await screen.findByRole('dialog', { name: '模拟兑换结果' })\n     fireEvent.click(within(nextResult).getByRole('button', { name: '查看我的卡包' }))\n     expect(screen.getByTestId('route').textContent).toBe('/card')\n@@ -296,13 +308,13 @@ describe('UX-E: page-local exchange flow', () => {\n     renderExchange()\n \n     const sheet = openCoupon()\n-    const submit = within(sheet).getByRole('button', { name: '立即兑换' }) as HTMLButtonElement\n+    const submit = chooseProject(sheet) as HTMLButtonElement\n     expect(submit.disabled).toBe(false)\n     fireEvent.click(submit)\n \n     const result = await screen.findByRole('dialog', { name: '兑换结果' })\n     expect(within(result).getByText('兑换完成，剩余泡泡值 300。可前往卡包查看新券。')).toBeTruthy()\n-    expect(mocks.redeem).toHaveBeenCalledWith(1)\n+    expect(mocks.redeem).toHaveBeenCalledWith(1, 12)\n     expect(mocks.reloadPoints).toHaveBeenCalledOnce()\n     expect(mocks.reloadCoupons).toHaveBeenCalledOnce()\n     expect(screen.getByTestId('route').textContent).toBe('/exchange')\ndiff --git a/src/pages/Exchange.tsx b/src/pages/Exchange.tsx\nindex fb7ed5b..95d7796 100644\n--- a/src/pages/Exchange.tsx\n+++ b/src/pages/Exchange.tsx\n@@ -6,7 +6,7 @@ import EmptyStateIllustration from '../components/mobile/EmptyStateIllustration'\n import PageContainer from '../components/mobile/PageContainer'\n import PromptOverlay from '../components/mobile/PromptOverlay'\n import BubbleValueRedeemCard from '../components/card/BubbleValueRedeemCard'\n-import { BottomSheet, Button, EmptyState, SegmentedControl } from '../components/ui'\n+import { Button, EmptyState, SegmentedControl } from '../components/ui'\n import { findRouteByPathname } from '../app/router/routes'\n import { runtimePolicy } from '../app/config/runtime'\n import { useUserPointsStat } from './points/usePointsFeed'\n@@ -15,6 +15,7 @@ import { EXCHANGE_COPY } from '../app/fixtures'\n import type { CouponRedeemView } from '../services/coupons'\n import { redeemExchangeProduct, type ExchangeRedeemResult } from '../services/exchange'\n import kitThumb from '../assets/brand/member/checkin-dearseed-kit.webp'\n+import ProjectSelectScreen from './exchange/ProjectSelectScreen'\n \n /** 接口 `image` 为空时的券图兜底（沿用已验收品牌图，不新增假字段）。 */\n const COUPON_COVER_FALLBACK = kitThumb\n@@ -49,7 +50,7 @@ const availabilityButtonLabel = (state: ExchangeAvailability): string =>\n  * 接口来源：`GET /api/coupons/index`（7002「优惠券管理」，券列表接口，用户 2026-09-29 提供图二）。\n  * 已确认：顶部「我的泡泡值 + 立即兑换」余额条、后台动态分类 Tab、两列体验券卡\n  *        （券图 / 名称 / 所需泡泡值 / 兑换量 / 已兑完遮罩 / 兑换按钮）、\n- *        点击卡片打开兑换弹窗（券图名 / x1 / 说明 / 泡泡值 / 立即兑换）。\n+ *        点击券卡打开全屏适用片区选择（券摘要 / 搜索分页 / 单选 / 确认兑换）。\n  *\n  * 数据口径（2026-09-29 与产品确认）：\n  * - 当前真实业务只开放「通用体验包」兑换；多分类 / 多券页面结构保留，作为后续恢复多体验券时的扩展位，\n@@ -143,14 +144,14 @@ export default function Exchange() {\n     couponListRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })\n   }\n \n-  const submit = async () => {\n+  const submit = async (projectId: number) => {\n     if (!activeProduct || availability !== 'redeemable' || submissionLock.current) return\n \n     submissionLock.current = true\n     setSubmitting(true)\n     setSubmitError(null)\n     try {\n-      const result = await redeemExchangeProduct(activeProduct.id)\n+      const result = await redeemExchangeProduct(activeProduct.id, projectId)\n       setActiveProductId(null)\n       setRedeemResult(result)\n \n@@ -301,66 +302,19 @@ export default function Exchange() {\n         <ChevronRight className=\"h-4 w-4 shrink-0 text-text-tertiary\" aria-hidden />\n       </button>\n \n-      {/* #39 体验券兑换弹窗（原型 §3） */}\n-      <BottomSheet open={activeProduct !== null} title=\"确认兑换\" onClose={closeRedeem}>\n-        {activeProduct && (\n-          <>\n-            <div className=\"flex gap-3\">\n-              <span className=\"relative block h-20 w-20 shrink-0 overflow-hidden rounded-coupon bg-surface-subtle\">\n-                {activeProduct.image || COUPON_COVER_FALLBACK ? (\n-                  <img\n-                    src={activeProduct.image || COUPON_COVER_FALLBACK}\n-                    alt=\"\"\n-                    className=\"h-full w-full object-cover\"\n-                  />\n-                ) : (\n-                  <span className=\"flex h-full w-full items-center justify-center text-text-tertiary\" aria-hidden>\n-                    <Ticket className=\"h-7 w-7\" />\n-                  </span>\n-                )}\n-              </span>\n-              <div className=\"min-w-0 flex-1\">\n-                <div className=\"flex items-start gap-2\">\n-                  <p className=\"min-w-0 flex-1 text-sm font-medium text-text-primary\">{activeProduct.name}</p>\n-                  <span className=\"shrink-0 text-sm text-text-secondary\">{EXCHANGE_COPY.quantity}</span>\n-                </div>\n-                <p className=\"mt-1 text-xs leading-5 text-text-secondary\">{activeProduct.desc}</p>\n-                <p className=\"mt-2 text-base font-semibold text-exchange-price-text\">\n-                  {activeProduct.cost}\n-                  <span className=\"ml-0.5 text-xs font-normal\">🫧 {EXCHANGE_COPY.costUnit}</span>\n-                </p>\n-              </div>\n-            </div>\n-\n-            <div className=\"mt-3 flex items-center justify-between rounded-control bg-surface-subtle px-3 py-2 text-xs\">\n-              <span className=\"text-text-secondary\">{EXCHANGE_COPY.balanceLabel}</span>\n-              <span className=\"font-medium text-text-primary\">{balance == null ? '--' : `${balance.toLocaleString()} 🫧`}</span>\n-            </div>\n-\n-            {(availability === 'sold-out' || availability === 'insufficient') && (\n-              <p className=\"mt-2 text-xs text-danger-text\" role=\"status\">\n-                {availability === 'sold-out' ? EXCHANGE_COPY.soldOut : EXCHANGE_COPY.insufficient}\n-              </p>\n-            )}\n-\n-            {submitError && (\n-              <p className=\"mt-2 text-xs text-danger-text\" role=\"alert\">\n-                {submitError}\n-              </p>\n-            )}\n-\n-            <Button\n-              size=\"large\"\n-              className=\"mt-4 w-full rounded-full\"\n-              loading={submitting}\n-              disabled={availability !== 'redeemable' || submitting}\n-              onClick={() => void submit()}\n-            >\n-              {submitting ? EXCHANGE_COPY.submitting : availabilityButtonLabel(availability)}\n-            </Button>\n-          </>\n-        )}\n-      </BottomSheet>\n+      {/* Approved 2026-10-14 full-screen in-page step: no URL or nested BottomSheet. */}\n+      {activeProduct && (\n+        <ProjectSelectScreen\n+          key={activeProduct.id}\n+          coupon={activeProduct}\n+          fallbackImage={COUPON_COVER_FALLBACK}\n+          balance={balance}\n+          submitting={submitting}\n+          submitError={submitError}\n+          onClose={closeRedeem}\n+          onConfirm={(projectId) => { void submit(projectId) }}\n+        />\n+      )}\n \n       <PromptOverlay\n         open={redeemResult !== null}\ndiff --git a/src/pages/exchange/ProjectSelectScreen.tsx b/src/pages/exchange/ProjectSelectScreen.tsx\nnew file mode 100644\nindex 0000000..4e91488\n--- /dev/null\n+++ b/src/pages/exchange/ProjectSelectScreen.tsx\n@@ -0,0 +1,267 @@\n+import { useEffect, useMemo, useRef, useState, type UIEvent } from 'react'\n+import { createPortal } from 'react-dom'\n+import { Check, ChevronLeft, Search, X } from 'lucide-react'\n+import type { CouponRedeemView } from '../../services/coupons'\n+import type { CouponProject } from '../../services/couponProjects'\n+import { useCouponProjects } from './useCouponProjects'\n+\n+interface Props {\n+  coupon: CouponRedeemView\n+  fallbackImage: string\n+  balance: number | null\n+  submitting: boolean\n+  submitError: string | null\n+  onClose: () => void\n+  onConfirm: (projectId: number) => void\n+}\n+\n+function ProjectOption({ project, checked, onChoose }: {\n+  project: CouponProject\n+  checked: boolean\n+  onChoose: () => void\n+}) {\n+  return (\n+    <button\n+      type=\"button\"\n+      role=\"radio\"\n+      aria-checked={checked}\n+      onClick={onChoose}\n+      className={`flex min-h-[76px] w-full items-center gap-3 rounded-[13px] border px-3 py-3 text-left shadow-[0_3px_16px_rgba(70,51,30,.035)] transition active:scale-[.993] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[#DF4800] ${\n+        checked ? 'border-[#EBA879] bg-[#FFF8F2]' : 'border-[#F1EAE2] bg-white'\n+      }`}\n+    >\n+      <span className={`flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-[1.5px] ${\n+        checked ? 'border-[#DF4800] bg-[#DF4800] text-white' : 'border-[#CCC4B9] bg-white'\n+      }`}>\n+        {checked && <Check size={13} strokeWidth={2.5} aria-hidden />}\n+      </span>\n+      <span className=\"min-w-0 flex-1\">\n+        <span className=\"block break-words text-[14px] font-semibold leading-[21px] text-[#28231E]\">{project.shop_name}</span>\n+        <span className=\"mt-[5px] block truncate text-[11px] leading-[17px] text-[#999086]\">{project.activity_name}</span>\n+      </span>\n+      {checked && <span className=\"shrink-0 rounded-full bg-[#FFEDE0] px-2 py-1 text-[10px] text-[#BF672C]\">已选择</span>}\n+    </button>\n+  )\n+}\n+\n+/** Full-screen, in-page exchange step. Selection survives every search and page change. */\n+export default function ProjectSelectScreen({\n+  coupon, fallbackImage, balance, submitting, submitError, onClose, onConfirm,\n+}: Props) {\n+  const [input, setInput] = useState('')\n+  const [query, setQuery] = useState('')\n+  const [selected, setSelected] = useState<CouponProject | null>(null)\n+  const scroller = useRef<HTMLElement>(null)\n+  const dialogRef = useRef<HTMLDivElement>(null)\n+  const { items, total, page, loading, loadingMore, hasMore, error, loadMoreError, restartRequired, reload, loadMore } =\n+    useCouponProjects(query)\n+\n+  useEffect(() => {\n+    const timeout = window.setTimeout(() => setQuery(input.trim()), 300)\n+    return () => window.clearTimeout(timeout)\n+  }, [input])\n+\n+  useEffect(() => { if (scroller.current) scroller.current.scrollTop = 0 }, [query])\n+\n+  // An in-page portal must own keyboard focus just like the shared Dialog.\n+  // Focus the back action (not the search input) to avoid opening the mobile keyboard.\n+  useEffect(() => {\n+    const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null\n+    const dialog = dialogRef.current\n+    const first = dialog?.querySelector<HTMLButtonElement>('button:not(:disabled)')\n+    first?.focus({ preventScroll: true })\n+    const keepFocusInside = (event: FocusEvent) => {\n+      if (dialog && event.target instanceof Node && !dialog.contains(event.target)) {\n+        first?.focus({ preventScroll: true })\n+      }\n+    }\n+    document.addEventListener('focusin', keepFocusInside)\n+    return () => {\n+      document.removeEventListener('focusin', keepFocusInside)\n+      previous?.focus({ preventScroll: true })\n+    }\n+  }, [])\n+\n+\n+  useEffect(() => {\n+    const onKeyDown = (event: KeyboardEvent) => {\n+      if (event.key === 'Escape' && !submitting) onClose()\n+      if (event.key === 'Tab') {\n+        const elements = [...(dialogRef.current?.querySelectorAll<HTMLElement>(\n+          'button:not(:disabled), input:not(:disabled), [tabindex]:not([tabindex=\"-1\"])',\n+        ) ?? [])]\n+        if (!elements.length) return\n+        const first = elements[0]\n+        const last = elements[elements.length - 1]\n+        if (event.shiftKey && document.activeElement === first) {\n+          event.preventDefault()\n+          last.focus()\n+        } else if (!event.shiftKey && document.activeElement === last) {\n+          event.preventDefault()\n+          first.focus()\n+        }\n+      }\n+    }\n+    window.addEventListener('keydown', onKeyDown)\n+    return () => window.removeEventListener('keydown', onKeyDown)\n+  }, [onClose, submitting])\n+\n+  const otherItems = useMemo(\n+    () => items.filter(item => item.id !== selected?.id),\n+    [items, selected?.id],\n+  )\n+\n+  const onScroll = (event: UIEvent<HTMLElement>) => {\n+    const target = event.currentTarget\n+    if (target.scrollHeight - target.scrollTop - target.clientHeight < 160) loadMore()\n+  }\n+\n+  return createPortal(\n+    <div\n+      ref={dialogRef}\n+      role=\"dialog\"\n+      aria-modal=\"true\"\n+      aria-label=\"选择适用片区\"\n+      className=\"fixed inset-0 z-[150] flex flex-col bg-[#FFFBF5] text-[#28231E]\"\n+      style={{ height: '100dvh' }}\n+    >\n+      <header className=\"shrink-0 pt-[env(safe-area-inset-top)]\">\n+        <div className=\"grid h-12 grid-cols-[44px_minmax(0,1fr)_44px] items-center px-4\">\n+          <button\n+            type=\"button\"\n+            disabled={submitting}\n+            onClick={onClose}\n+            aria-label=\"返回兑换专区\"\n+            className=\"flex h-10 w-10 items-center justify-center rounded-full active:bg-[#F7EEE3] disabled:opacity-50\"\n+          >\n+            <ChevronLeft size={23} strokeWidth={1.9} aria-hidden />\n+          </button>\n+          <h1 className=\"text-center text-[16px] font-semibold\">选择适用片区</h1>\n+        </div>\n+\n+        <section className=\"mx-4 mt-[7px] rounded-[15px] border border-[#F2E9DF] bg-white px-[13px] pb-3 pt-[13px] shadow-[0_3px_16px_rgba(70,51,30,.035)]\" aria-label=\"待兑换券详情\">\n+          <div className=\"mb-[10px] flex items-center justify-between text-[11px] text-[#99826B]\">\n+            <span>待兑换体验券</span><span className=\"font-medium text-[#7B756F]\">×1</span>\n+          </div>\n+          <div className=\"flex items-center gap-3\">\n+            <img src={coupon.image || fallbackImage} alt=\"\" className=\"h-[67px] w-[67px] shrink-0 rounded-[10px] border border-[#F3EFE7] object-cover\" />\n+            <div className=\"min-w-0 flex-1\">\n+              <p className=\"truncate text-[15px] font-semibold leading-5\">{coupon.name}</p>\n+              <p className=\"mt-1 truncate text-[11px] leading-[17px] text-[#8B8379]\">{coupon.desc}</p>\n+              <p className=\"mt-[7px] text-[11px] font-medium text-[#996B30]\">\n+                <span className=\"mr-1 text-[17px] font-bold text-[#DF4800]\">{coupon.cost}</span>泡泡值\n+              </p>\n+            </div>\n+          </div>\n+        </section>\n+\n+        <div className=\"px-4 pb-[10px] pt-[15px]\">\n+          <div className=\"flex h-[46px] items-center gap-[10px] rounded-full border border-[#EEE6DC] bg-white px-[15px] focus-within:border-[#ED9C69]\">\n+            <Search size={19} className=\"shrink-0 text-[#AAA095]\" aria-hidden />\n+            <input\n+              type=\"search\"\n+              aria-label=\"搜索片区或项目名称\"\n+              placeholder=\"搜索片区或项目名称\"\n+              enterKeyHint=\"search\"\n+              autoComplete=\"off\"\n+              spellCheck={false}\n+              value={input}\n+              onChange={event => setInput(event.target.value)}\n+              className=\"h-full min-w-0 flex-1 bg-transparent text-[14px] outline-none placeholder:text-[#AFA79F] [&::-webkit-search-cancel-button]:hidden\"\n+            />\n+            {input && (\n+              <button type=\"button\" onClick={() => setInput('')} aria-label=\"清空搜索\"\n+                className=\"flex h-[25px] w-[25px] shrink-0 items-center justify-center rounded-full bg-[#F3F0EB] text-[#978D84]\">\n+                <X size={14} aria-hidden />\n+              </button>\n+            )}\n+          </div>\n+          <div className=\"flex items-center justify-between px-[2px] pt-3 text-[11px] text-[#A3998E]\">\n+            <span>单选一个适用项目</span><span>每次加载 15 条</span>\n+          </div>\n+        </div>\n+      </header>\n+\n+      <section\n+        ref={scroller}\n+        onScroll={onScroll}\n+        aria-label=\"片区搜索结果\"\n+        className=\"min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain px-4 pb-4\"\n+      >\n+        <div role=\"radiogroup\" aria-label=\"选择一个适用片区\">\n+          {selected && (\n+            <section className=\"mb-[15px]\" aria-label=\"已选片区\">\n+              <p className=\"mb-[9px] mt-1 flex items-center gap-[6px] px-[2px] text-[12px] font-semibold text-[#AE662E]\">\n+                <Check size={14} className=\"text-[#DF4800]\" aria-hidden />已选片区 · 搜索后仍保留\n+              </p>\n+              <ProjectOption project={selected} checked onChoose={() => setSelected(selected)} />\n+            </section>\n+          )}\n+          <div className=\"mb-[6px] flex min-h-[29px] items-center justify-between px-[2px] text-[12px]\">\n+            <strong className=\"font-semibold\">搜索结果</strong>\n+            <span className=\"text-[11px] text-[#9B9389]\">{loading ? '正在搜索…' : `共 ${total} 条`}</span>\n+          </div>\n+          {loading && page === 0 ? (\n+            <div role=\"status\" aria-label=\"正在加载项目\" className=\"space-y-[9px]\">\n+              {[0, 1, 2].map(n => <div key={n} className=\"h-[76px] animate-pulse rounded-[13px] border border-[#F3EDE5] bg-[#FBF5EC]\" />)}\n+            </div>\n+          ) : error ? (\n+            <div role=\"alert\" className=\"py-10 text-center text-[13px] text-[#716B64]\">\n+              <p>片区加载失败：{error}</p>\n+              <button type=\"button\" onClick={reload} className=\"mt-3 rounded-full border border-[#EBA879] px-5 py-2 text-[#DF4800]\">重试</button>\n+            </div>\n+          ) : otherItems.length === 0 ? (\n+            <div className=\"py-10 text-center\">\n+              <Search size={23} className=\"mx-auto text-[#B7A48E]\" aria-hidden />\n+              <p className=\"mt-3 text-[14px] text-[#605950]\">没有找到相关片区</p>\n+              <p className=\"mt-[6px] text-[12px] leading-5 text-[#AEA49A]\">\n+                换个名称再试试{selected ? '，已选片区仍可继续兑换' : ''}\n+              </p>\n+            </div>\n+          ) : (\n+            <div className=\"flex flex-col gap-[9px]\">\n+              {otherItems.map(item => (\n+                <ProjectOption key={item.id} project={item} checked={false} onChoose={() => setSelected(item)} />\n+              ))}\n+            </div>\n+          )}\n+        </div>\n+        {loadingMore && <p role=\"status\" className=\"py-4 text-center text-[12px] text-[#AAA299]\">正在加载下一页…</p>}\n+        {loadMoreError && <div role=\"alert\" className=\"py-4 text-center text-[12px] text-[#716B64]\">{loadMoreError}\n+          <button type=\"button\" onClick={restartRequired ? reload : loadMore} className=\"ml-2 text-[#DF4800]\">重试</button>\n+        </div>}\n+        {hasMore && !loadingMore && !loadMoreError && (\n+          <button type=\"button\" onClick={loadMore} className=\"w-full py-4 text-center text-[12px] text-[#AA6C42]\">\n+            继续加载下一页\n+          </button>\n+        )}\n+        {!loading && !error && page > 0 && !hasMore &&\n+          <p className=\"py-4 text-center text-[11px] text-[#AAA299]\">已加载全部 {total} 条项目</p>}\n+      </section>\n+\n+      <footer className=\"shrink-0 border-t border-[#F1E9DF] bg-[#FFFBF5] px-4 pb-[max(16px,env(safe-area-inset-bottom))] pt-3\">\n+        {submitError && <p role=\"alert\" className=\"mb-2 text-[12px] text-danger-text\">{submitError}</p>}\n+        {coupon.soldOut && <p role=\"status\" className=\"mb-2 text-[12px] text-danger-text\">体验券已兑完</p>}\n+        {!coupon.soldOut && balance !== null && balance < coupon.cost && (\n+          <p role=\"status\" className=\"mb-2 text-[12px] text-danger-text\">泡泡值不足，暂时无法兑换</p>\n+        )}\n+        {!coupon.soldOut && balance === null && (\n+          <p role=\"status\" className=\"mb-2 text-[12px] text-danger-text\">当前余额不可用，请稍后重试</p>\n+        )}\n+        <div className=\"mb-3 flex items-baseline justify-between gap-2 text-[12px] text-[#93867A]\">\n+          <span>泡泡值余额 <b className=\"text-[#38302A]\">{balance?.toLocaleString() ?? '--'}</b></span>\n+          <span>本次消耗 <b className=\"text-[14px] text-[#DF4800]\">{coupon.cost}</b> 泡泡值</span>\n+        </div>\n+        <button\n+          type=\"button\"\n+          disabled={!selected || submitting || balance === null || balance < coupon.cost || coupon.soldOut}\n+          onClick={() => { if (selected) onConfirm(selected.id) }}\n+          className=\"h-[49px] w-full rounded-full bg-[#DF4800] text-[15px] font-semibold text-white active:bg-[#C83D00] disabled:bg-[#DCCFC1]\"\n+        >\n+          {submitting ? '兑换中…' : selected ? '确认兑换' : '请选择适用片区'}\n+        </button>\n+      </footer>\n+    </div>,\n+    document.body,\n+  )\n+}\ndiff --git a/src/pages/exchange/useCouponProjects.test.tsx b/src/pages/exchange/useCouponProjects.test.tsx\nnew file mode 100644\nindex 0000000..49bfdfe\n--- /dev/null\n+++ b/src/pages/exchange/useCouponProjects.test.tsx\n@@ -0,0 +1,50 @@\n+import { act, renderHook, waitFor } from '@testing-library/react'\n+import { afterEach, describe, expect, it, vi } from 'vitest'\n+\n+const mocks = vi.hoisted(() => ({ fetch: vi.fn() }))\n+vi.mock('../../services/couponProjects', () => ({ fetchCouponProjects: mocks.fetch }))\n+\n+import { useCouponProjects } from './useCouponProjects'\n+\n+const row = (id: number) => ({\n+  id, shop_name: 'Mock·片区' + id, activity_name: 'Mock·体验券', discountcard_no: 'DC' + id,\n+})\n+const page = (n: number, total: number, ids: number[], lastPage = 2) => ({\n+  data: ids.map(row), current_page: n, per_page: 15, total, last_page: lastPage,\n+})\n+\n+afterEach(() => mocks.fetch.mockReset())\n+\n+describe('project pagination search generation', () => {\n+  it('restores page one after a changed server catalog instead of infinitely retrying stale page two', async () => {\n+    mocks.fetch.mockResolvedValueOnce(page(1, 16, [1, 2]))\n+      .mockResolvedValueOnce(page(2, 17, [3]))\n+      .mockResolvedValueOnce(page(1, 17, [1, 2, 3]))\n+    const hook = renderHook(() => useCouponProjects(''))\n+    await waitFor(() => expect(hook.result.current.page).toBe(1))\n+\n+    act(() => hook.result.current.loadMore())\n+    await waitFor(() => expect(hook.result.current.restartRequired).toBe(true))\n+    expect(hook.result.current.items.map(p => p.id)).toEqual([1, 2])\n+\n+    act(() => hook.result.current.reload())\n+    await waitFor(() => expect(hook.result.current.total).toBe(17))\n+    expect(hook.result.current.restartRequired).toBe(false)\n+    expect(mocks.fetch.mock.calls.map(call => call[1])).toEqual([1, 2, 1])\n+    hook.unmount()\n+  })\n+\n+  it('ignores an old search response after a new keyword is entered', async () => {\n+    let completeOld!: (value: ReturnType<typeof page>) => void\n+    mocks.fetch.mockImplementationOnce(() => new Promise(resolve => { completeOld = resolve }))\n+      .mockResolvedValueOnce(page(1, 1, [30], 1))\n+    const hook = renderHook(({ keyword }) => useCouponProjects(keyword), {\n+      initialProps: { keyword: '南沙' },\n+    })\n+    hook.rerender({ keyword: '天河' })\n+    await waitFor(() => expect(hook.result.current.items.map(p => p.id)).toEqual([30]))\n+    await act(async () => completeOld(page(1, 1, [10], 1)))\n+    expect(hook.result.current.items.map(p => p.id)).toEqual([30])\n+    hook.unmount()\n+  })\n+})\ndiff --git a/src/pages/exchange/useCouponProjects.ts b/src/pages/exchange/useCouponProjects.ts\nnew file mode 100644\nindex 0000000..678e7a7\n--- /dev/null\n+++ b/src/pages/exchange/useCouponProjects.ts\n@@ -0,0 +1,83 @@\n+import { useCallback, useEffect, useRef, useState } from 'react'\n+import { fetchCouponProjects, type CouponProject } from '../../services/couponProjects'\n+\n+interface ProjectQueryState {\n+  items: CouponProject[]\n+  page: number\n+  lastPage: number\n+  total: number\n+  loading: boolean\n+  loadingMore: boolean\n+  error: string | null\n+  loadMoreError: string | null\n+  restartRequired: boolean\n+}\n+\n+const initial: ProjectQueryState = {\n+  items: [], page: 0, lastPage: 1, total: 0,\n+  loading: true, loadingMore: false, error: null, loadMoreError: null, restartRequired: false,\n+}\n+const errorText = (error: unknown) => error instanceof Error ? error.message : '片区加载失败，请重试'\n+\n+/** Server pagination per query. Older search responses must never override newer results. */\n+export function useCouponProjects(keyword: string) {\n+  const [retry, setRetry] = useState(0)\n+  const [state, setState] = useState<ProjectQueryState>(initial)\n+  const generation = useRef(0)\n+  const morePending = useRef(false)\n+  const query = keyword.trim()\n+\n+  useEffect(() => {\n+    const current = ++generation.current\n+    morePending.current = false\n+    setState({ ...initial })\n+    void fetchCouponProjects(query, 1).then(\n+      result => {\n+        if (generation.current !== current) return\n+        setState({\n+          items: result.data, page: result.current_page, lastPage: result.last_page,\n+          total: result.total, loading: false, loadingMore: false,\n+          error: null, loadMoreError: null, restartRequired: false,\n+        })\n+      },\n+      error => {\n+        if (generation.current !== current) return\n+        setState({ ...initial, loading: false, error: errorText(error) })\n+      },\n+    )\n+    return () => { generation.current += 1 }\n+  }, [query, retry])\n+\n+  const reload = useCallback(() => setRetry(value => value + 1), [])\n+  const hasMore = !state.loading && !state.error && state.page > 0 && state.page < state.lastPage\n+  const loadMore = useCallback(() => {\n+    if (!hasMore || state.loadingMore || state.restartRequired || morePending.current) return\n+    morePending.current = true\n+    const current = generation.current\n+    const next = state.page + 1\n+    setState(prev => ({ ...prev, loadingMore: true, loadMoreError: null }))\n+    void fetchCouponProjects(query, next).then(\n+      result => {\n+        if (generation.current !== current) return\n+        setState(prev => {\n+          // Never append a changed catalog as if it were a consistent page.\n+          if (result.total !== prev.total || result.last_page !== prev.lastPage) {\n+            return { ...prev, loadingMore: false, loadMoreError: '列表已变化，请重新加载', restartRequired: true }\n+          }\n+          const rows = new Map(prev.items.map(item => [item.id, item]))\n+          result.data.forEach(item => rows.set(item.id, item))\n+          return { ...prev, items: [...rows.values()], page: result.current_page,\n+            loadingMore: false, loadMoreError: null, restartRequired: false }\n+        })\n+        morePending.current = false\n+      },\n+      error => {\n+        if (generation.current !== current) return\n+        morePending.current = false\n+        setState(prev => ({ ...prev, loadingMore: false, loadMoreError: errorText(error) }))\n+      },\n+    )\n+  }, [hasMore, query, state.loadingMore, state.page])\n+\n+  return { ...state, hasMore, reload, loadMore }\n+}\ndiff --git a/src/services/couponProjects.test.ts b/src/services/couponProjects.test.ts\nnew file mode 100644\nindex 0000000..29dd25b\n--- /dev/null\n+++ b/src/services/couponProjects.test.ts\n@@ -0,0 +1,40 @@\n+import { afterEach, describe, expect, it, vi } from 'vitest'\n+\n+const mocks = vi.hoisted(() => ({ request: vi.fn() }))\n+vi.mock('./http', () => ({\n+  httpClient: { request: mocks.request },\n+  createBusinessError: (message: string) => new Error(message),\n+}))\n+import { fetchCouponProjects, COUPON_PROJECT_INDEX_PATH, COUPON_PROJECT_SEARCH_PATH } from './couponProjects'\n+\n+const response = (page: number) => ({\n+  code: 0, data: {\n+    data: [{ id: 31, shop_name: '测试片区', activity_name: '体验券活动', discountcard_no: 'DC1' }],\n+    current_page: page, per_page: 15, total: 16, last_page: 2,\n+  },\n+})\n+afterEach(() => mocks.request.mockReset())\n+\n+describe('coupon project search protocol', () => {\n+  it('uses unfiltered GET index for the initial 15; no invalid blank search', async () => {\n+    mocks.request.mockResolvedValue(response(1))\n+    await expect(fetchCouponProjects('')).resolves.toMatchObject({ total: 16 })\n+    expect(mocks.request).toHaveBeenCalledWith({\n+      method: 'GET', url: COUPON_PROJECT_INDEX_PATH, params: { page: 1, pageSize: 15 },\n+    })\n+  })\n+  it('searches server-side with the required nonblank shop_name and paginates by 15', async () => {\n+    mocks.request.mockResolvedValue(response(2))\n+    await fetchCouponProjects('  南沙  ', 2)\n+    expect(mocks.request).toHaveBeenCalledWith({\n+      method: 'POST', url: COUPON_PROJECT_SEARCH_PATH,\n+      data: { shop_name: '南沙', page: 2, pageSize: 15 },\n+    })\n+  })\n+  it('rejects failed or malformed pages rather than exposing a false empty list', async () => {\n+    mocks.request.mockResolvedValueOnce({ code: 500, message: '查询失败', data: [] })\n+      .mockResolvedValueOnce({ code: 0, data: { ...response(1).data, current_page: 3 } })\n+    await expect(fetchCouponProjects('南沙')).rejects.toThrow('查询失败')\n+    await expect(fetchCouponProjects('南沙')).rejects.toThrow('分页数据异常')\n+  })\n+})\ndiff --git a/src/services/couponProjects.ts b/src/services/couponProjects.ts\nnew file mode 100644\nindex 0000000..2f18478\n--- /dev/null\n+++ b/src/services/couponProjects.ts\n@@ -0,0 +1,49 @@\n+import { z } from 'zod'\n+import { parseApiEnvelope } from './contracts/apiEnvelope'\n+import { httpClient } from './http'\n+\n+export const COUPON_PROJECT_INDEX_PATH = '/api/couponskbs/index'\n+export const COUPON_PROJECT_SEARCH_PATH = '/api/coupons/getProjects'\n+export const COUPON_PROJECT_PAGE_SIZE = 15\n+\n+const projectSchema = z.object({\n+  id: z.number().int().positive(),\n+  shop_name: z.string(),\n+  activity_name: z.string(),\n+  discountcard_no: z.string(),\n+}).passthrough()\n+\n+const pageSchema = z.object({\n+  data: z.array(projectSchema),\n+  current_page: z.number().int().positive(),\n+  per_page: z.number().int().positive(),\n+  total: z.number().int().nonnegative(),\n+  last_page: z.number().int().positive(),\n+}).passthrough()\n+\n+export type CouponProject = z.infer<typeof projectSchema>\n+export type CouponProjectsPage = z.infer<typeof pageSchema>\n+\n+/**\n+ * Blank keyword uses the public project index's documented page/pageSize DTO.\n+ * /coupons/getProjects requires a nonempty shop_name, so never send it an empty\n+ * search or fabricate a wildcard. Both endpoints expose the same project shape.\n+ */\n+export async function fetchCouponProjects(keyword: string, page = 1): Promise<CouponProjectsPage> {\n+  if (!Number.isInteger(page) || page < 1) throw new Error('项目页码无效')\n+  const term = keyword.trim()\n+  const payload = await httpClient.request<unknown>(term\n+    ? { method: 'POST', url: COUPON_PROJECT_SEARCH_PATH,\n+        data: { shop_name: term, page, pageSize: COUPON_PROJECT_PAGE_SIZE } }\n+    : { method: 'GET', url: COUPON_PROJECT_INDEX_PATH,\n+        params: { page, pageSize: COUPON_PROJECT_PAGE_SIZE } })\n+\n+  const result = parseApiEnvelope(payload, pageSchema, {\n+    contract: 'coupons.projects', fallbackMessage: '适用片区加载失败',\n+  })\n+  if (result.current_page !== page || result.per_page > COUPON_PROJECT_PAGE_SIZE ||\n+      result.data.length > COUPON_PROJECT_PAGE_SIZE) {\n+    throw new Error('片区列表分页数据异常，请重试')\n+  }\n+  return result\n+}\ndiff --git a/src/services/exchange.test.ts b/src/services/exchange.test.ts\nindex d559d90..cd3a80a 100644\n--- a/src/services/exchange.test.ts\n+++ b/src/services/exchange.test.ts\n@@ -23,14 +23,14 @@ describe('coupon exchange backend contract', () => {\n       data: { coupon_id: 3, points: 28 },\n     })\n \n-    await expect(redeemExchangeProduct(3)).resolves.toEqual({\n+    await expect(redeemExchangeProduct(3, 12)).resolves.toEqual({\n       couponId: 3,\n       points: 28,\n     })\n     expect(mocks.request).toHaveBeenCalledWith({\n       method: 'POST',\n       url: EXCHANGE_REDEEM_PATH,\n-      data: { id: 3 },\n+      data: { id: 3, project_id: 12 },\n     })\n   })\n \n@@ -46,7 +46,7 @@ describe('coupon exchange backend contract', () => {\n       data: [],\n     })\n \n-    await expect(redeemExchangeProduct(3)).rejects.toThrow(message)\n+    await expect(redeemExchangeProduct(3, 12)).rejects.toThrow(message)\n   })\n \n   it('rejects malformed successful data instead of showing a false success', async () => {\n@@ -57,6 +57,6 @@ describe('coupon exchange backend contract', () => {\n       data: { coupon_id: 3 },\n     })\n \n-    await expect(redeemExchangeProduct(3)).rejects.toThrow('coupons.exchange.data')\n+    await expect(redeemExchangeProduct(3, 12)).rejects.toThrow('coupons.exchange.data')\n   })\n })\ndiff --git a/src/services/exchange.ts b/src/services/exchange.ts\nindex 71652f0..ee1c015 100644\n--- a/src/services/exchange.ts\n+++ b/src/services/exchange.ts\n@@ -26,11 +26,12 @@ export interface ExchangeRedeemResult {\n  * both from the authenticated session and owns stock, deduction, ledger and issue\n  * compensation semantics.\n  */\n-export async function redeemExchangeProduct(productId: number): Promise<ExchangeRedeemResult> {\n+export async function redeemExchangeProduct(productId: number, projectId: number): Promise<ExchangeRedeemResult> {\n+  if (!Number.isInteger(projectId) || projectId <= 0) throw new Error('请选择适用片区')\n   const payload = await httpClient.request<unknown>({\n     method: 'POST',\n     url: EXCHANGE_REDEEM_PATH,\n-    data: { id: productId },\n+    data: { id: productId, project_id: projectId },\n   })\n \n   const data = parseApiEnvelope(payload, exchangeRedeemDataSchema, {\ndiff --git a/tests/e2e/uxe-exchange.spec.ts b/tests/e2e/uxe-exchange.spec.ts\nindex 686e2cb..8a12d46 100644\n--- a/tests/e2e/uxe-exchange.spec.ts\n+++ b/tests/e2e/uxe-exchange.spec.ts\n@@ -1,6 +1,6 @@\n import { expect, test } from '@playwright/test'\n \n-test('@formal-h5 UX-E exchange tabs and confirmation remain within one URL', async ({ page }) => {\n+test('@formal-h5 UX-E exchange tabs and full-screen project selection remain within one URL', async ({ page }) => {\n   await page.goto('/exchange', { waitUntil: 'domcontentloaded' })\n   const urlBefore = new URL(page.url())\n   expect(urlBefore.pathname).toBe('/exchange')\n@@ -16,11 +16,24 @@ test('@formal-h5 UX-E exchange tabs and confirmation remain within one URL', asy\n   const firstCoupon = list.locator('button').first()\n   await expect(firstCoupon).toBeVisible()\n   await firstCoupon.click()\n-  await expect(page.getByRole('dialog', { name: '确认兑换' })).toBeVisible()\n+  const selector = page.getByRole('dialog', { name: '选择适用片区' })\n+  await expect(selector).toBeVisible()\n   expect(new URL(page.url()).pathname + new URL(page.url()).search).toBe('/exchange')\n+  const chosen = selector.getByRole('radio', { name: /Mock·深圳南山科技园/ })\n+  await expect(chosen).toBeVisible()\n+  await chosen.click()\n+  await expect(selector.getByRole('region', { name: '已选片区' })).toContainText('深圳南山科技园')\n \n+  // Server search is unrelated to the chosen item: the selection must persist.\n+  await selector.getByRole('searchbox', { name: '搜索片区或项目名称' }).fill('天河')\n+  await expect(selector.getByRole('radio', { name: /Mock·广州天河体育西/ })).toBeVisible()\n+  await expect(selector.getByRole('region', { name: '已选片区' })).toContainText('深圳南山科技园')\n+  // The stable preview fixture has 128 points while the coupon costs 200:\n+  // preserve genuine insufficiency instead of mutating the fixture to force success.\n+  await expect(selector.getByText('泡泡值不足，暂时无法兑换')).toBeVisible()\n+  await expect(selector.getByRole('button', { name: '确认兑换' })).toBeDisabled()\n   await page.keyboard.press('Escape')\n-  await expect(page.getByRole('dialog', { name: '确认兑换' })).toHaveCount(0)\n+  await expect(selector).toHaveCount(0)\n   expect(new URL(page.url()).pathname + new URL(page.url()).search).toBe('/exchange')\n })",
          "actualWorkers": 2
        },
        "id": "chromium-mobile",
        "name": "chromium-mobile",
        "testDir": "/home/runner/work/dr-card-web/dr-card-web/tests/e2e",
        "testIgnore": [],
        "testMatch": [
          "**/*.@(spec|test).?(c|m)[jt]s?(x)"
        ],
        "timeout": 30000
      }
    ],
    "quiet": false,
    "reporter": [
      [
        "line",
        null
      ],
      [
        "html",
        {
          "outputFolder": "playwright-report",
          "open": "never"
        }
      ],
      [
        "json",
        {
          "outputFile": "test-results/playwright-results.json"
        }
      ]
    ],
    "reportSlowTests": {
      "max": 5,
      "threshold": 300000
    },
    "shard": null,
    "tags": [],
    "updateSnapshots": "missing",
    "updateSourceMethod": "patch",
    "version": "1.63.0",
    "workers": 2,
    "webServer": null
  },
  "suites": [
    {
      "title": "test-gate.spec.ts",
      "file": "test-gate.spec.ts",
      "column": 0,
      "line": 0,
      "specs": [
        {
          "title": "@test-gate build identity matches the production-like test policy",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 69,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-14T11:02:17.714Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-c3b6e0ce5d8c5d8734e5",
          "file": "test-gate.spec.ts",
          "line": 31,
          "column": 1
        },
        {
          "title": "@test-gate browser entry does not start the app router",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 1206,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-14T11:02:17.689Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8ed37c8041402a2a6b34",
          "file": "test-gate.spec.ts",
          "line": 40,
          "column": 1
        },
        {
          "title": "@test-gate / shows the unsupported-host notice in a browser",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 1265,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-14T11:02:17.872Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-b030d57053896c93a550",
          "file": "test-gate.spec.ts",
          "line": 51,
          "column": 3
        },
        {
          "title": "@test-gate /profile shows the unsupported-host notice in a browser",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 1048,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-14T11:02:19.289Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8605535a2807e17fcccc",
          "file": "test-gate.spec.ts",
          "line": 51,
          "column": 3
        },
        {
          "title": "@test-gate /settings shows the unsupported-host notice in a browser",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 1125,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-14T11:02:19.344Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-4a2fdc9884c1725de4f8",
          "file": "test-gate.spec.ts",
          "line": 51,
          "column": 3
        },
        {
          "title": "@test-gate gated browser entry issues no business API request",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 961,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-14T11:02:20.358Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-09f38a2b8ed735e192d8",
          "file": "test-gate.spec.ts",
          "line": 65,
          "column": 1
        },
        {
          "title": "@test-gate injected native host bypasses the notice and starts the app",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 1006,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-14T11:02:20.498Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8caf237c4d42513fff2f",
          "file": "test-gate.spec.ts",
          "line": 80,
          "column": 1
        }
      ]
    }
  ],
  "errors": [],
  "stats": {
    "startTime": "2026-10-14T11:02:16.627Z",
    "duration": 4940.81,
    "expected": 7,
    "skipped": 0,
    "unexpected": 0,
    "flaky": 0
  }
}