From 2f934bfbd2caf32cf7b7dfe67a221aca22257d58 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 09:55:20 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E3=83=9A=E3=83=BC=E3=82=B8=E3=81=94?= =?UTF-8?q?=E3=81=A8=E3=81=AE=E3=83=A1=E3=82=BF=E3=82=BF=E3=82=B0=E3=81=A8?= =?UTF-8?q?SEO=E6=A7=8B=E9=80=A0=E5=8C=96=E3=83=87=E3=83=BC=E3=82=BF?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TanStack Routerのhead機能でルート別のtitle/description/og:titleを設定 - マジックリンクページ(shifts/submit,view,reissue,welcome)と認証ページをnoindex化 - LPにFAQPage JSON-LDを追加(faqsを共有モジュールに切り出し) - index.htmlにOrganization/WebSite JSON-LDを追加 - robots.txtに/shifts,/welcomeのDisallowを追加 - sitemap.xmlにlastmodを追加 Co-Authored-By: Claude Opus 4.6 (1M context) --- index.html | 18 ++++++ public/robots.txt | 2 + public/sitemap.xml | 3 + src/components/features/LandingPage/faqs.ts | 15 +++++ src/components/features/LandingPage/index.tsx | 17 +---- src/helpers/seo/index.ts | 64 +++++++++++++++++++ src/routes/__root.tsx | 3 +- src/routes/_auth/dashboard.tsx | 4 ++ .../_auth/shiftboard.$recruitmentId.tsx | 4 ++ src/routes/_unregistered/shifts.reissue.tsx | 4 ++ src/routes/_unregistered/shifts.submit.tsx | 4 ++ src/routes/_unregistered/shifts.view.tsx | 4 ++ src/routes/_unregistered/welcome.tsx | 4 ++ src/routes/index.tsx | 21 ++++++ src/routes/privacy.tsx | 8 +++ src/routes/terms.tsx | 8 +++ 16 files changed, 166 insertions(+), 17 deletions(-) create mode 100644 src/components/features/LandingPage/faqs.ts create mode 100644 src/helpers/seo/index.ts diff --git a/index.html b/index.html index a0d84537..03063c04 100644 --- a/index.html +++ b/index.html @@ -61,6 +61,24 @@ } } + +
diff --git a/public/robots.txt b/public/robots.txt index bc7dd244..aa3b2d12 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,6 +1,8 @@ User-agent: * Disallow: /dashboard Disallow: /shiftboard +Disallow: /shifts +Disallow: /welcome Allow: / Sitemap: https://shiftori.app/sitemap.xml diff --git a/public/sitemap.xml b/public/sitemap.xml index 02ba08ea..c3e3d225 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -2,16 +2,19 @@ https://shiftori.app + 2026-04-16 monthly 1.0 https://shiftori.app/privacy + 2026-04-16 yearly 0.3 https://shiftori.app/terms + 2026-04-16 yearly 0.3 diff --git a/src/components/features/LandingPage/faqs.ts b/src/components/features/LandingPage/faqs.ts new file mode 100644 index 00000000..e2d4df5c --- /dev/null +++ b/src/components/features/LandingPage/faqs.ts @@ -0,0 +1,15 @@ +export type Faq = { + q: string; + a: string; +}; + +export const faqs: Faq[] = [ + { + q: "料金はかかりますか?", + a: "無料です", + }, + { + q: "スタッフがメールアドレスを持っていない場合は?", + a: "現在はメールアドレスが必須です 将来的にほかの方法にも対応予定です", + }, +]; diff --git a/src/components/features/LandingPage/index.tsx b/src/components/features/LandingPage/index.tsx index 016b888c..9cb50deb 100644 --- a/src/components/features/LandingPage/index.tsx +++ b/src/components/features/LandingPage/index.tsx @@ -4,6 +4,7 @@ import { Link as RouterLink } from "@tanstack/react-router"; import type { ReactNode } from "react"; import type { IconType } from "react-icons"; import { LuCalendarCheck, LuLink, LuSend } from "react-icons/lu"; +import { type Faq, faqs } from "./faqs"; export const LandingPage = () => { return ( @@ -160,22 +161,6 @@ const PointCard = ({ icon: Icon, title, body }: Point) => ( ); -type Faq = { - q: string; - a: string; -}; - -const faqs: Faq[] = [ - { - q: "料金はかかりますか?", - a: "無料です", - }, - { - q: "スタッフがメールアドレスを持っていない場合は?", - a: "現在はメールアドレスが必須です 将来的にほかの方法にも対応予定です", - }, -]; - const FaqSection = () => ( diff --git a/src/helpers/seo/index.ts b/src/helpers/seo/index.ts new file mode 100644 index 00000000..fc2f72da --- /dev/null +++ b/src/helpers/seo/index.ts @@ -0,0 +1,64 @@ +/** + * SEO helpers for TanStack Router's `head` option. + * + * TanStack Router's public `meta` type is typed as HTML `` attributes, + * but its runtime also recognizes `{ title }` (renders ``) and + * `{ "script:ld+json": payload }` (renders a JSON-LD `<script>`). The entries + * here produce runtime-valid tags; the final array is cast so the `head` + * option accepts it. + * @link https://tanstack.com/router/latest/docs/framework/react/guide/document-head-management + */ +import type { JSX } from "react"; + +const SITE_NAME = "シフトリ"; +const SITE_URL = "https://shiftori.app"; + +type MetaList = NonNullable<JSX.IntrinsicElements["meta"]>[]; + +type MetaEntry = + | { title: string } + | { name: string; content: string } + | { property: string; content: string } + | { "script:ld+json": Record<string, unknown> }; + +type BuildMetaOptions = { + title: string; + description?: string; + /** Set true for pages that should not be indexed (magic-link pages, authed pages). */ + noindex?: boolean; + /** Canonical path (e.g. "/terms"). Adds `og:url`. */ + canonical?: string; +}; + +/** + * Build route meta tags for TanStack Router's `head` option. + * - Appends the site name to the title (except when the title is the site name itself) + * - Mirrors description to `og:description` + * - Adds `robots: noindex, nofollow` when `noindex` is set + */ +export const buildMeta = ({ title, description, noindex, canonical }: BuildMetaOptions): MetaList => { + const fullTitle = title === SITE_NAME ? title : `${title} | ${SITE_NAME}`; + const entries: MetaEntry[] = [{ title: fullTitle }, { property: "og:title", content: fullTitle }]; + + if (description) { + entries.push({ name: "description", content: description }); + entries.push({ property: "og:description", content: description }); + } + + if (canonical) { + entries.push({ property: "og:url", content: `${SITE_URL}${canonical}` }); + } + + if (noindex) { + entries.push({ name: "robots", content: "noindex, nofollow" }); + } + + return entries as unknown as MetaList; +}; + +/** + * Wrap a JSON-LD payload as a `head.meta` entry. Rendered as + * `<script type="application/ld+json">...</script>` by TanStack Router. + */ +export const jsonLdMeta = (payload: Record<string, unknown>): MetaList => + [{ "script:ld+json": payload }] as unknown as MetaList; diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 9e8cdece..c3dd0304 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,4 +1,4 @@ -import { createRootRoute, Outlet, useRouterState } from "@tanstack/react-router"; +import { createRootRoute, HeadContent, Outlet, useRouterState } from "@tanstack/react-router"; import { useEffect } from "react"; import { Toaster } from "@/src/components/ui/toaster"; import { sendPageView } from "@/src/helpers/gtm"; @@ -16,6 +16,7 @@ const PageViewTracker = () => { export const Route = createRootRoute({ component: () => ( <> + <HeadContent /> <PageViewTracker /> <Outlet /> <Toaster /> diff --git a/src/routes/_auth/dashboard.tsx b/src/routes/_auth/dashboard.tsx index d207ddfd..70ec2963 100644 --- a/src/routes/_auth/dashboard.tsx +++ b/src/routes/_auth/dashboard.tsx @@ -5,8 +5,12 @@ import { api } from "@/convex/_generated/api"; import { DashboardContent } from "@/src/components/features/Dashboard/DashboardContent"; import { Animation } from "@/src/components/templates/Animation"; import { RootContentWrapper } from "@/src/components/templates/RootContentWrapper"; +import { buildMeta } from "@/src/helpers/seo"; export const Route = createFileRoute("/_auth/dashboard")({ + head: () => ({ + meta: buildMeta({ title: "ダッシュボード", noindex: true }), + }), component: DashboardPage, }); diff --git a/src/routes/_auth/shiftboard.$recruitmentId.tsx b/src/routes/_auth/shiftboard.$recruitmentId.tsx index 137d2fba..803ef64d 100644 --- a/src/routes/_auth/shiftboard.$recruitmentId.tsx +++ b/src/routes/_auth/shiftboard.$recruitmentId.tsx @@ -5,8 +5,12 @@ import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; import { ShiftBoardPage } from "@/src/components/features/ShiftBoard/ShiftBoardPage"; import { Animation } from "@/src/components/templates/Animation"; +import { buildMeta } from "@/src/helpers/seo"; export const Route = createFileRoute("/_auth/shiftboard/$recruitmentId")({ + head: () => ({ + meta: buildMeta({ title: "シフト表", noindex: true }), + }), component: ShiftBoardRoute, }); diff --git a/src/routes/_unregistered/shifts.reissue.tsx b/src/routes/_unregistered/shifts.reissue.tsx index 354ed390..a180038b 100644 --- a/src/routes/_unregistered/shifts.reissue.tsx +++ b/src/routes/_unregistered/shifts.reissue.tsx @@ -9,11 +9,15 @@ import { ReissueDone } from "@/src/components/features/StaffView/ReissueDone"; import { ReissueForm } from "@/src/components/features/StaffView/ReissueForm"; import { StaffLayout } from "@/src/components/templates/StaffLayout"; import { FullPageSpinner } from "@/src/components/ui/FullPageSpinner"; +import { buildMeta } from "@/src/helpers/seo"; export const Route = createFileRoute("/_unregistered/shifts/reissue")({ validateSearch: (search: Record<string, unknown>) => ({ recruitmentId: search.recruitmentId as string, }), + head: () => ({ + meta: buildMeta({ title: "シフト閲覧リンクの再発行", noindex: true }), + }), component: ReissueRoute, }); diff --git a/src/routes/_unregistered/shifts.submit.tsx b/src/routes/_unregistered/shifts.submit.tsx index ea24e741..1ef794f2 100644 --- a/src/routes/_unregistered/shifts.submit.tsx +++ b/src/routes/_unregistered/shifts.submit.tsx @@ -8,12 +8,16 @@ import { ShiftSubmitPage } from "@/src/components/features/StaffSubmit/ShiftSubm import { RateLimitedView } from "@/src/components/features/StaffView/RateLimitedView"; import { ErrorBoundary } from "@/src/components/ui/ErrorBoundary"; import { FullPageSpinner } from "@/src/components/ui/FullPageSpinner"; +import { buildMeta } from "@/src/helpers/seo"; import { useStaffSession } from "@/src/hooks/useStaffSession"; export const Route = createFileRoute("/_unregistered/shifts/submit")({ validateSearch: (search: Record<string, unknown>) => ({ token: (search.token as string) || undefined, }), + head: () => ({ + meta: buildMeta({ title: "希望シフト提出", noindex: true }), + }), component: ShiftSubmitRoute, }); diff --git a/src/routes/_unregistered/shifts.view.tsx b/src/routes/_unregistered/shifts.view.tsx index f0841ab5..be127b5c 100644 --- a/src/routes/_unregistered/shifts.view.tsx +++ b/src/routes/_unregistered/shifts.view.tsx @@ -8,12 +8,16 @@ import { ShiftViewPage } from "@/src/components/features/StaffView/ShiftViewPage import { StaffLayout } from "@/src/components/templates/StaffLayout"; import { ErrorBoundary } from "@/src/components/ui/ErrorBoundary"; import { FullPageSpinner } from "@/src/components/ui/FullPageSpinner"; +import { buildMeta } from "@/src/helpers/seo"; import { useStaffSession } from "@/src/hooks/useStaffSession"; export const Route = createFileRoute("/_unregistered/shifts/view")({ validateSearch: (search: Record<string, unknown>) => ({ token: (search.token as string) || undefined, }), + head: () => ({ + meta: buildMeta({ title: "シフト確認", noindex: true }), + }), component: ShiftViewRoute, }); diff --git a/src/routes/_unregistered/welcome.tsx b/src/routes/_unregistered/welcome.tsx index 4a15d5d9..43aed9fd 100644 --- a/src/routes/_unregistered/welcome.tsx +++ b/src/routes/_unregistered/welcome.tsx @@ -1,7 +1,11 @@ import { Box, Text } from "@chakra-ui/react"; import { createFileRoute } from "@tanstack/react-router"; +import { buildMeta } from "@/src/helpers/seo"; export const Route = createFileRoute("/_unregistered/welcome")({ + head: () => ({ + meta: buildMeta({ title: "Welcome", noindex: true }), + }), component: RouteComponent, }); diff --git a/src/routes/index.tsx b/src/routes/index.tsx index a1899680..01aad0a2 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,8 +1,29 @@ import { useAuth } from "@clerk/clerk-react"; import { createFileRoute, Navigate } from "@tanstack/react-router"; import { LandingPage } from "@/src/components/features/LandingPage"; +import { faqs } from "@/src/components/features/LandingPage/faqs"; +import { buildMeta, jsonLdMeta } from "@/src/helpers/seo"; export const Route = createFileRoute("/")({ + head: () => ({ + meta: [ + ...buildMeta({ + title: "シフトリ", + description: + "少人数のお店のシフト作成をもっとラクに リンクを送るだけで希望シフトを収集 スタッフのアカウント登録も不要 無料ではじめられるシフト管理ツール", + canonical: "/", + }), + ...jsonLdMeta({ + "@context": "https://schema.org", + "@type": "FAQPage", + mainEntity: faqs.map((f) => ({ + "@type": "Question", + name: f.q, + acceptedAnswer: { "@type": "Answer", text: f.a }, + })), + }), + ], + }), component: IndexPage, }); diff --git a/src/routes/privacy.tsx b/src/routes/privacy.tsx index 7eb1c256..3b64225a 100644 --- a/src/routes/privacy.tsx +++ b/src/routes/privacy.tsx @@ -1,6 +1,14 @@ import { createFileRoute } from "@tanstack/react-router"; import { PrivacyPolicy } from "@/src/components/features/PrivacyPolicy"; +import { buildMeta } from "@/src/helpers/seo"; export const Route = createFileRoute("/privacy")({ + head: () => ({ + meta: buildMeta({ + title: "プライバシーポリシー", + description: "シフトリのプライバシーポリシー", + canonical: "/privacy", + }), + }), component: PrivacyPolicy, }); diff --git a/src/routes/terms.tsx b/src/routes/terms.tsx index 98f75ed9..c0b27dce 100644 --- a/src/routes/terms.tsx +++ b/src/routes/terms.tsx @@ -1,6 +1,14 @@ import { createFileRoute } from "@tanstack/react-router"; import { Terms } from "@/src/components/features/Terms"; +import { buildMeta } from "@/src/helpers/seo"; export const Route = createFileRoute("/terms")({ + head: () => ({ + meta: buildMeta({ + title: "利用規約", + description: "シフトリの利用規約", + canonical: "/terms", + }), + }), component: Terms, });