Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 18 additions & 68 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, interactive-widget=resizes-content" />
<meta name="description" content="OneHook is a private, intentional dating app for one meaningful connection at a time — no endless swiping or ghosting, just real, end-to-end encrypted conversations." />
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content" />
<meta name="description" content="OneHook is a private, intentional dating app: one meaningful match at a time. No endless swiping or ghosting, just real, end-to-end encrypted conversations." />
<meta name="keywords" content="dating app, intentional dating, serious dating, one match at a time, meaningful connection, private messaging, end-to-end encrypted chat, secure dating app, online dating, relationships, couples, anti-swiping dating app" />
<meta name="author" content="OneHook" />
<meta name="robots" content="index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1" />
Expand All @@ -20,23 +20,23 @@
<meta property="og:site_name" content="OneHook" />
<meta property="og:url" content="https://onehook.club/" />
<meta property="og:locale" content="en_US" />
<meta property="og:title" content="OneHook — Intentional Messaging App for One Real Connection" />
<meta property="og:description" content="OneHook is a private, intentional dating app for one meaningful connection at a time — no endless swiping or ghosting, just real, end-to-end encrypted conversations." />
<meta property="og:image" content="https://onehook.club/media/onehook_white_inside-512.png" />
<meta property="og:image:secure_url" content="https://onehook.club/media/onehook_white_inside-512.png" />
<meta property="og:title" content="OneHook — Intentional Dating App for One Real Connection" />
<meta property="og:description" content="OneHook is a private, intentional dating app: one meaningful match at a time. No endless swiping or ghosting, just real, end-to-end encrypted conversations." />
<meta property="og:image" content="https://onehook.club/media/og/onehook-og.png" />
<meta property="og:image:secure_url" content="https://onehook.club/media/og/onehook-og.png" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="512" />
<meta property="og:image:height" content="512" />
<meta property="og:image:alt" content="OneHook logo: a silver heart with a navy 1 inside, on a white background." />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="OneHook: the intentional dating app for one real connection at a time." />

<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@onehookclub" />
<meta name="twitter:creator" content="@onehookclub" />
<meta name="twitter:title" content="OneHook — Intentional Messaging App for One Real Connection" />
<meta name="twitter:description" content="OneHook is a private, intentional dating app for one meaningful connection at a time — no endless swiping or ghosting, just real, end-to-end encrypted conversations." />
<meta name="twitter:image" content="https://onehook.club/media/onehook_white_inside-512.png" />
<meta name="twitter:image:alt" content="OneHook logo: a silver heart with a navy 1 inside, on a white background." />
<meta name="twitter:title" content="OneHook — Intentional Dating App for One Real Connection" />
<meta name="twitter:description" content="OneHook is a private, intentional dating app: one meaningful match at a time. No endless swiping or ghosting, just real, end-to-end encrypted conversations." />
<meta name="twitter:image" content="https://onehook.club/media/og/onehook-og.png" />
<meta name="twitter:image:alt" content="OneHook: the intentional dating app for one real connection at a time." />
<link rel="icon" type="image/png" sizes="180x180" href="/media/onehook_white_inside-180.png" />
<link rel="shortcut icon" type="image/png" sizes="180x180" href="/media/onehook_white_inside-180.png" />
<link rel="apple-touch-icon" href="/media/onehook_white_inside-180.png" />
Expand All @@ -48,10 +48,10 @@
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600&family=Playfair+Display:ital,wght@0,900;1,900&family=JetBrains+Mono&display=swap"
/>
<title>OneHook — Intentional Messaging App for One Real Connection</title>
<title>OneHook — Intentional Dating App for One Real Connection</title>

<!-- Structured data: Organization, WebSite, the OneHook app, and FAQ. Keep factual to the
product (see README): one match at a time, end-to-end encrypted conversations. -->
<!-- Site-wide structured data: Organization and WebSite. Page-specific data (e.g. the app's
SoftwareApplication on the homepage) comes from src/config/seo-routes.ts. -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
Expand Down Expand Up @@ -86,58 +86,6 @@
"description": "Intentional dating app for one meaningful connection at a time.",
"inLanguage": "en",
"publisher": { "@id": "https://onehook.club/#organization" }
},
{
"@type": "SoftwareApplication",
"name": "OneHook",
"operatingSystem": "iOS, Android",
"applicationCategory": "SocialNetworkingApplication",
"url": "https://onehook.club/",
"image": "https://onehook.club/media/onehook_white_inside-512.png",
"description": "OneHook is a private, intentional dating app. Focus on one match at a time and build a real relationship through end-to-end encrypted conversations instead of endless swiping.",
"publisher": { "@id": "https://onehook.club/#organization" },
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
}
},
{
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "What is OneHook?",
"acceptedAnswer": {
"@type": "Answer",
"text": "OneHook is an intentional dating app that lets you focus on one match at a time until you build a real connection or decide to unmatch, instead of endlessly swiping."
}
},
{
"@type": "Question",
"name": "How is OneHook different from other dating apps?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Instead of a feed of endless swipes and dozens of open chats, OneHook gives you one connection at a time, so both people actually show up and invest in a real conversation."
}
},
{
"@type": "Question",
"name": "Is OneHook private and secure?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes. Messages between matches are end-to-end encrypted, so your conversations stay between the two of you — not even OneHook can read them."
}
},
{
"@type": "Question",
"name": "Is OneHook free?",
"acceptedAnswer": {
"@type": "Answer",
"text": "You can start dating on OneHook for free, with optional Super and Infinity memberships that unlock more ways to connect."
}
}
]
}
]
}
Expand Down Expand Up @@ -191,6 +139,8 @@
#app-splash .oh-heart { animation: none; }
}
</style>
<!-- Without JavaScript the splash is never dismissed; hide it so the pre-rendered page shows. -->
<noscript><style>#app-splash { display: none; }</style></noscript>
</head>
<body>
<div id="app-splash" aria-hidden="true">
Expand Down
24 changes: 14 additions & 10 deletions infra/stacks/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
* been removed.
*/

import { APP_STORE_LINKS as STORE_LISTINGS } from '../../src/config/app-store-links.ts';

/** AWS accounts by role. */
export const ACCOUNTS = {
/** Owns the production frontend stack and the onehook.club hosted zone. */
Expand Down Expand Up @@ -47,19 +49,21 @@ export const APP_LINK_SUBDOMAIN = 'app';
/**
* Store destinations for the smart link.
*
* KEEP IN SYNC with `src/config/signup.config.ts` (`APP_STORE_LINKS`), which the web UI uses for its
* own "get the app" buttons. Infra cannot import from `src` (separate tsconfig), so the values are
* duplicated here deliberately; `deployment-pipeline.test.ts` asserts they match.
* The ios/android listings come from `src/config/app-store-links.ts`, the single place they are set
* (the web UI reads the same file). Only the infra-specific fallback is defined here.
*/
export const APP_STORE_LINKS = {
ios: 'https://apps.apple.com/app/onehook',
android: 'https://play.google.com/store/apps/details?id=club.onehook.android',
export const APP_STORE_LINKS: {
readonly ios: string | null;
readonly android: string | null;
readonly fallback: string;
} = {
...STORE_LISTINGS,
/**
* Desktop / unknown devices cannot install the app, so they land on the marketing page rather
* than an App Store page that would immediately reject them.
* Desktops cannot install the app, and a phone whose store listing is not live yet must not be
* sent to a store 404, so both go to the web sign-in page, where the app is usable in a browser.
*/
fallback: 'https://onehook.club/app',
} as const;
fallback: 'https://onehook.club/login',
};

/**
* Mr OneHook AI chatbot custom domains (Azure App Service, OneHookUXEnhancement/infra).
Expand Down
35 changes: 23 additions & 12 deletions infra/stacks/frontend-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,9 +157,8 @@ export class FrontendStack extends Stack {
// 2. Clean URLs for the pre-built marketing pages: /messenger → /messenger/index.html (and
// /messenger/ → /messenger/index.html). The build writes one HTML file per public route with its
// own title, description and canonical in the raw <head>, so crawlers and link previews that
// don't run JavaScript see the right page. Paths with a file extension are untouched, and app
// routes with no pre-built file (e.g. /app, /login) still fall back to /index.html via the
// error responses.
// don't run JavaScript see the right page. Paths with a file extension are untouched. A path
// with no pre-built file is not a page, and gets the 404 page via the error responses below.
const wwwHost = props.includeWww ? `www.${props.domainName}` : '';
const cleanUrlFunction = new CloudFrontFunction(this, 'CleanUrlFunction', {
comment: 'Redirects www to the apex and maps /<route> to /<route>/index.html',
Expand Down Expand Up @@ -215,17 +214,21 @@ function handler(event) {
functionAssociations: [{ function: cleanUrlFunction, eventType: FunctionEventType.VIEWER_REQUEST }],
},
defaultRootObject: 'index.html',
// Every real page has its own pre-built file, so a missing object means the URL does not exist.
// Answer with a real 404 and the not-found page instead of a 200 with the homepage, which
// search engines treat as a "soft 404" duplicate of the homepage. S3 behind an OAI without
// ListBucket reports missing keys as 403, so 403 is mapped the same way.
errorResponses: [
{
httpStatus: 404,
responseHttpStatus: 200,
responsePagePath: '/index.html',
responseHttpStatus: 404,
responsePagePath: '/404.html',
ttl: Duration.minutes(5),
},
{
httpStatus: 403,
responseHttpStatus: 200,
responsePagePath: '/index.html',
responseHttpStatus: 404,
responsePagePath: '/404.html',
ttl: Duration.minutes(5),
},
],
Expand Down Expand Up @@ -308,20 +311,28 @@ function handler(event) {
var isIOS = /iPhone|iPad|iPod/i.test(ua) || (/Macintosh/i.test(ua) && /Mobile\\/|Touch/i.test(ua));
var isAndroid = /Android/i.test(ua);

// A store link is null until that listing is published; that platform then uses the fallback
// (web sign-in) rather than a store 404.
var IOS = ${JSON.stringify(APP_STORE_LINKS.ios)};
var ANDROID = ${JSON.stringify(APP_STORE_LINKS.android)};
var location = ${JSON.stringify(APP_STORE_LINKS.fallback)};
if (isIOS) {
location = ${JSON.stringify(APP_STORE_LINKS.ios)};
} else if (isAndroid) {
location = ${JSON.stringify(APP_STORE_LINKS.android)};
if (isIOS && IOS) {
location = IOS;
} else if (isAndroid && ANDROID) {
location = ANDROID;
}

// 302 (not 301) on purpose: browsers cache a 301 indefinitely, which would pin early visitors to
// the fallback after a store listing goes live.
return {
statusCode: 302,
statusDescription: 'Found',
headers: {
'location': { value: location },
// Never let a browser or proxy reuse one platform's redirect for another device.
'cache-control': { value: 'no-cache, no-store, must-revalidate' }
'cache-control': { value: 'no-cache, no-store, must-revalidate' },
// Tells crawlers and caches the answer depends on the device.
'vary': { value: 'User-Agent' }
}
};
}
Expand Down
9 changes: 1 addition & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,7 @@
"prettier": "^3.2.5",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite-plugin-sitemap": "^0.8.2"
"typescript": "~5.8.2"
},
"engines": {
"node": ">=20.0.0",
Expand Down
Binary file added public/media/og/onehook-og-messenger.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/media/og/onehook-og-pricing.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/media/og/onehook-og.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 6 additions & 3 deletions public/robots.txt
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
User-agent: *
Allow: /

# Private, signed-in and invite-only areas carry no public SEO value.
Disallow: /app
# /login and /redeem are indexable entry pages (listed in sitemap.xml). /app is a
# signed-in shell kept out of search with a `noindex` meta tag in its own pre-built
# HTML (see src/config/seo-routes.ts). It stays crawlable on purpose: Google can only
# honor `noindex` on a page it is allowed to fetch, and a blocked URL can still be
# indexed from external links. Onboarding and the post-sign-in auth setup are deep
# internal steps with no entry value, so they remain fully disallowed.
Disallow: /onboarding
Disallow: /auth/
Disallow: /redeem

Sitemap: https://onehook.club/sitemap.xml
4 changes: 3 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Contact } from './components/legal/Contact';
import { Careers } from './components/careers/Careers';
import { Science } from './components/science/Science';
import { Messenger } from './components/messenger/Messenger';
import { NotFound } from './components/common/NotFound';
import { MrOneHookDock } from './components/Chatbot/MrOneHookDock';
import { getCognitoAuth, initializeCognitoAuth } from './lib/cognito-auth';
import { config } from './utils/env.config';
Expand Down Expand Up @@ -225,7 +226,8 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
{/* Unknown URL: a real not-found page (CloudFront serves it with HTTP 404). */}
<Route path="*" element={<NotFound />} />
</Routes>
</Router>
</ToastProvider>
Expand Down
9 changes: 9 additions & 0 deletions src/app/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ApiError } from '../lib/api-client';
import { StateApi } from '../api/state';
import { getCognitoAuth } from '../lib/cognito-auth';
import { LoadingSpinner } from '../components/common/LoadingSpinner';
import { Seo } from '../components/common/Seo';
import { useToast } from '../../src/components/common/Toast';
import { DiscoveryView } from '../features/discovery/DiscoveryView';
import { LikesYouView } from '../features/discovery/LikesYouView';
Expand Down Expand Up @@ -252,6 +253,14 @@ export function AppContent() {
return (
<CallProvider currentUserId={currentUser.id}>
<div className={`app-container ${resolvedTheme === 'dark' ? 'app-theme-dark' : 'app-theme-light'} h-[100dvh] bg-bg text-text flex flex-col overflow-hidden`}>
{/* Signed-in tab title. The crawler-facing "/app" prerender says "Sign In Required" (see
seo-routes.ts); a logged-in member instead sees a clean title. Always noindex. */}
<Seo
title="OneHook"
description="Your private OneHook space — your match, end-to-end encrypted chats, calls and profile."
path="/app"
noindex
/>
{/* Common Full-Width Header expanding from left to right with OneHook logo in the centre */}
<TopBar
activeTab={appState}
Expand Down
Loading
Loading