feat: complete M0 — legal pages, consent, tip_views metrics, account deletion UI

- /legal/terms and /legal/privacy pages (linked from sign-in)
- Consent (consentGiven=true) recorded on first Google sign-in
- tip_views table: one row per tip served — enables activation + reaction rate queries
- tip_views purged on account deletion
- Delete account button on /connect (confirm → revoke tokens → purge data → sign out)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-15 09:09:08 +00:00
parent 888f8b9a99
commit f6c890213b
18 changed files with 438 additions and 9 deletions

View File

@@ -0,0 +1,2 @@
- main [ref=e2] [cursor=pointer]:
- generic [ref=e3]: ···

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,119 @@
- generic [ref=e3]:
- generic [ref=e5]:
- generic [ref=e8]:
- img "Google" [ref=e10]
- generic [ref=e11]: Sign in with Google
- generic [ref=e12]:
- generic [ref=e14]:
- heading "Sign in" [level=1] [ref=e15]
- paragraph [ref=e16]:
- text: to continue to
- button "alogins.net" [ref=e17] [cursor=pointer]
- generic [ref=e18]:
- generic [ref=e21]:
- generic [ref=e26]:
- textbox "Email or phone" [active] [ref=e27]
- generic:
- generic: Email or phone
- paragraph [ref=e28]:
- link "Forgot email?" [ref=e29] [cursor=pointer]:
- /url: /signin/v2/usernamerecovery?app_domain=https://o.alogins.net&client_id=225787180325-cesuikl95j1qg3aijh4j10n8n7usf65p.apps.googleusercontent.com&code_challenge=oJlG0iJD7GEnZ_p0tMbi56r9QGirq6lY0KHNKh-A7sI&code_challenge_method=S256&continue=https://accounts.google.com/signin/oauth/legacy/consent?authuser%3Dunknown%26part%3DAJi8hAP_s0wcqLhX3B4DTcLADDDkyjYYD_dTsSJST_xu2o-PkobLfbuk1XhXVndyBN6gkOnR0zh89fZIeMovlOB4w8i9PC5L8hRlhSDi_OSh3ki0cAZ8RRH7CMPl0NmSZbAb7253b7Sq7Uj7dN8TmsRtXQbgjYmbhyQtpViFok9UeZM7XROiV83I_xwzDvOMQD1WQkkCPL_ZjRyGIpPgmLbBXxUWwGnWs7x0CLlb2wOYMM4diy8wjIIVzACtLq0g_hnnf0y_mxrQYevSjMx1y6vLEeMZrKz4zXxSwX11OP6adWx8v9lVviJvM53GJLq4oV46GDTGcxuXLkt9W6FBgRJqMoh1oulT8tHVf1O4VG6FuQkvXnppbkH8b9OMEnqTaVlL1DVwPrvaEytsqZ79DQ74hKi9NiK3RE9wkvTaSElEARWCxILbbRjXR-GKwwybfKjQwGUZ9t2sYcJdZK3ygn8vZIYDrPIKQM8g27tJTfNj5yjjYJZsv-EMRSSvheUtOMdNBzy7fqqBKvxGQEO3iDfgy88NdOrw1AktvipSuf5E5uVlJ5KPLTq5J8TuKHO9TQcmBu9Fn1U1_NwlvMkvWbPJ2zbkshNe2q200XCnKF6Wz1bLG7sukKzEoyJkmc3x7poz1z9JQ4f7%26flowName%3DGeneralOAuthFlow%26as%3DS356240196%253A1776175595013373%26client_id%3D225787180325-cesuikl95j1qg3aijh4j10n8n7usf65p.apps.googleusercontent.com%23&dsh=S356240196:1776175595013373&flowName=GeneralOAuthLite&o2v=2&opparams=%253F&rart=ANgoxceHU_HzI34dur-e3VKjuSW_62nNyS0F-NAT8vowVRgzLCH8LgeU-dR_jmgEfEI3TjNPYuzeB6EBCMum_GguMnKcbkqUwFjfpff8YscBkT3joDYra5Q&redirect_uri=https://o.alogins.net/api/auth/callback&response_type=code&scope=openid+email+profile&service=lso&state=dfjtgpxO8h1eS83EqakZj
- generic [ref=e31]:
- button "Next" [ref=e33]
- link "Create account" [ref=e35] [cursor=pointer]:
- /url: /lifecycle/flows/signup?app_domain=https://o.alogins.net&client_id=225787180325-cesuikl95j1qg3aijh4j10n8n7usf65p.apps.googleusercontent.com&code_challenge=oJlG0iJD7GEnZ_p0tMbi56r9QGirq6lY0KHNKh-A7sI&code_challenge_method=S256&continue=https://accounts.google.com/signin/oauth/legacy/consent?authuser%3Dunknown%26part%3DAJi8hAP_s0wcqLhX3B4DTcLADDDkyjYYD_dTsSJST_xu2o-PkobLfbuk1XhXVndyBN6gkOnR0zh89fZIeMovlOB4w8i9PC5L8hRlhSDi_OSh3ki0cAZ8RRH7CMPl0NmSZbAb7253b7Sq7Uj7dN8TmsRtXQbgjYmbhyQtpViFok9UeZM7XROiV83I_xwzDvOMQD1WQkkCPL_ZjRyGIpPgmLbBXxUWwGnWs7x0CLlb2wOYMM4diy8wjIIVzACtLq0g_hnnf0y_mxrQYevSjMx1y6vLEeMZrKz4zXxSwX11OP6adWx8v9lVviJvM53GJLq4oV46GDTGcxuXLkt9W6FBgRJqMoh1oulT8tHVf1O4VG6FuQkvXnppbkH8b9OMEnqTaVlL1DVwPrvaEytsqZ79DQ74hKi9NiK3RE9wkvTaSElEARWCxILbbRjXR-GKwwybfKjQwGUZ9t2sYcJdZK3ygn8vZIYDrPIKQM8g27tJTfNj5yjjYJZsv-EMRSSvheUtOMdNBzy7fqqBKvxGQEO3iDfgy88NdOrw1AktvipSuf5E5uVlJ5KPLTq5J8TuKHO9TQcmBu9Fn1U1_NwlvMkvWbPJ2zbkshNe2q200XCnKF6Wz1bLG7sukKzEoyJkmc3x7poz1z9JQ4f7%26flowName%3DGeneralOAuthFlow%26as%3DS356240196%253A1776175595013373%26client_id%3D225787180325-cesuikl95j1qg3aijh4j10n8n7usf65p.apps.googleusercontent.com%23&dsh=S356240196:1776175595013373&flowEntry=SignUp&flowName=GlifWebSignIn&o2v=2&opparams=%253F&rart=ANgoxceHU_HzI34dur-e3VKjuSW_62nNyS0F-NAT8vowVRgzLCH8LgeU-dR_jmgEfEI3TjNPYuzeB6EBCMum_GguMnKcbkqUwFjfpff8YscBkT3joDYra5Q&redirect_uri=https://o.alogins.net/api/auth/callback&response_type=code&scope=openid+email+profile&service=lso&signInUrl=https://accounts.google.com/signin/oauth?app_domain%3Dhttps://o.alogins.net%26client_id%3D225787180325-cesuikl95j1qg3aijh4j10n8n7usf65p.apps.googleusercontent.com%26code_challenge%3DoJlG0iJD7GEnZ_p0tMbi56r9QGirq6lY0KHNKh-A7sI%26code_challenge_method%3DS256%26continue%3Dhttps://accounts.google.com/signin/oauth/legacy/consent?authuser%253Dunknown%2526part%253DAJi8hAP_s0wcqLhX3B4DTcLADDDkyjYYD_dTsSJST_xu2o-PkobLfbuk1XhXVndyBN6gkOnR0zh89fZIeMovlOB4w8i9PC5L8hRlhSDi_OSh3ki0cAZ8RRH7CMPl0NmSZbAb7253b7Sq7Uj7dN8TmsRtXQbgjYmbhyQtpViFok9UeZM7XROiV83I_xwzDvOMQD1WQkkCPL_ZjRyGIpPgmLbBXxUWwGnWs7x0CLlb2wOYMM4diy8wjIIVzACtLq0g_hnnf0y_mxrQYevSjMx1y6vLEeMZrKz4zXxSwX11OP6adWx8v9lVviJvM53GJLq4oV46GDTGcxuXLkt9W6FBgRJqMoh1oulT8tHVf1O4VG6FuQkvXnppbkH8b9OMEnqTaVlL1DVwPrvaEytsqZ79DQ74hKi9NiK3RE9wkvTaSElEARWCxILbbRjXR-GKwwybfKjQwGUZ9t2sYcJdZK3ygn8vZIYDrPIKQM8g27tJTfNj5yjjYJZsv-EMRSSvheUtOMdNBzy7fqqBKvxGQEO3iDfgy88NdOrw1AktvipSuf5E5uVlJ5KPLTq5J8TuKHO9TQcmBu9Fn1U1_NwlvMkvWbPJ2zbkshNe2q200XCnKF6Wz1bLG7sukKzEoyJkmc3x7poz1z9JQ4f7%2526flowName%253DGeneralOAuthFlow%2526as%253DS356240196%25253A1776175595013373%2526client_id%253D225787180325-cesuikl95j1qg3aijh4j10n8n7usf65p.apps.googleusercontent.com%2523%26dsh%3DS356240196:1776175595013373%26flowName%3DGeneralOAuthLite%26o2v%3D2%26opparams%3D%25253F%26rart%3DANgoxceHU_HzI34dur-e3VKjuSW_62nNyS0F-NAT8vowVRgzLCH8LgeU-dR_jmgEfEI3TjNPYuzeB6EBCMum_GguMnKcbkqUwFjfpff8YscBkT3joDYra5Q%26redirect_uri%3Dhttps://o.alogins.net/api/auth/callback%26response_type%3Dcode%26scope%3Dopenid%2Bemail%2Bprofile%26service%3Dlso%26state%3DdfjtgpxO8h1eS83EqakZj&state=dfjtgpxO8h1eS83EqakZj
- contentinfo [ref=e36]:
- combobox [ref=e39] [cursor=pointer]:
- option "Afrikaans"
- option "azərbaycan"
- option "bosanski"
- option "català"
- option "Čeština"
- option "Cymraeg"
- option "Dansk"
- option "Deutsch"
- option "eesti"
- option "English (United Kingdom)"
- option "English (United States)" [selected]
- option "Español (España)"
- option "Español (Latinoamérica)"
- option "euskara"
- option "Filipino"
- option "Français (Canada)"
- option "Français (France)"
- option "Gaeilge"
- option "galego"
- option "Hrvatski"
- option "Indonesia"
- option "isiZulu"
- option "íslenska"
- option "Italiano"
- option "Kiswahili"
- option "latviešu"
- option "lietuvių"
- option "magyar"
- option "Melayu"
- option "Nederlands"
- option "norsk"
- option "ozbek"
- option "polski"
- option "Português (Brasil)"
- option "Português (Portugal)"
- option "română"
- option "shqip"
- option "Slovenčina"
- option "slovenščina"
- option "srpski (latinica)"
- option "Suomi"
- option "Svenska"
- option "Tiếng Việt"
- option "Türkçe"
- option "Ελληνικά"
- option "беларуская"
- option "български"
- option "кыргызча"
- option "қазақ тілі"
- option "македонски"
- option "монгол"
- option "Русский"
- option "српски (ћирилица)"
- option "Українська"
- option "ქართული"
- option "հայերեն"
- option "‫עברית‬‎"
- option "‫اردو‬‎"
- option "‫العربية‬‎"
- option "‫فارسی‬‎"
- option "አማርኛ"
- option "नेपाली"
- option "मराठी"
- option "हिन्दी"
- option "অসমীয়া"
- option "বাংলা"
- option "ਪੰਜਾਬੀ"
- option "ગુજરાતી"
- option "ଓଡ଼ିଆ"
- option "தமிழ்"
- option "తెలుగు"
- option "ಕನ್ನಡ"
- option "മലയാളം"
- option "සිංහල"
- option "ไทย"
- option "ລາວ"
- option "မြန်မာ"
- option "ខ្មែរ"
- option "한국어"
- option "中文(香港)"
- option "日本語"
- option "简体中文"
- option "繁體中文"
- list [ref=e40]:
- listitem [ref=e41]:
- link "Help" [ref=e42] [cursor=pointer]:
- /url: https://support.google.com/accounts?hl=en-US&p=account_iph
- listitem [ref=e43]:
- link "Privacy" [ref=e44] [cursor=pointer]:
- /url: https://accounts.google.com/TOS?loc=LV&hl=en-US&privacy=true
- listitem [ref=e45]:
- link "Terms" [ref=e46] [cursor=pointer]:
- /url: https://accounts.google.com/TOS?loc=LV&hl=en-US

View File

@@ -0,0 +1,16 @@
- main [ref=e2]:
- generic [ref=e3]:
- heading "oO" [level=1] [ref=e4]
- paragraph [ref=e5]: one tip. right now.
- link "Continue with Google" [ref=e7] [cursor=pointer]:
- /url: /api/auth/login?redirectTo=/connect
- img [ref=e8]
- text: Continue with Google
- paragraph [ref=e13]:
- text: By continuing you agree to our
- link "Terms" [ref=e14] [cursor=pointer]:
- /url: /legal/terms
- text: and
- link "Privacy Policy" [ref=e15] [cursor=pointer]:
- /url: /legal/privacy
- text: .

View File

@@ -0,0 +1,2 @@
- main [ref=e2] [cursor=pointer]:
- generic [ref=e3]: ···

View File

@@ -0,0 +1,16 @@
- main [ref=e2]:
- generic [ref=e3]:
- heading "oO" [level=1] [ref=e4]
- paragraph [ref=e5]: one tip. right now.
- link "Continue with Google" [ref=e7] [cursor=pointer]:
- /url: /api/auth/login?redirectTo=/connect
- img [ref=e8]
- text: Continue with Google
- paragraph [ref=e13]:
- text: By continuing you agree to our
- link "Terms" [ref=e14] [cursor=pointer]:
- /url: /legal/terms
- text: and
- link "Privacy Policy" [ref=e15] [cursor=pointer]:
- /url: /legal/privacy
- text: .

View File

@@ -0,0 +1,119 @@
- generic [ref=e3]:
- generic [ref=e5]:
- generic [ref=e8]:
- img "Google" [ref=e10]
- generic [ref=e11]: Sign in with Google
- generic [ref=e12]:
- generic [ref=e14]:
- heading "Sign in" [level=1] [ref=e15]
- paragraph [ref=e16]:
- text: to continue to
- button "alogins.net" [ref=e17] [cursor=pointer]
- generic [ref=e18]:
- generic [ref=e21]:
- generic [ref=e26]:
- textbox "Email or phone" [active] [ref=e27]
- generic:
- generic: Email or phone
- paragraph [ref=e28]:
- link "Forgot email?" [ref=e29] [cursor=pointer]:
- /url: /signin/v2/usernamerecovery?app_domain=https://o.alogins.net&client_id=225787180325-cesuikl95j1qg3aijh4j10n8n7usf65p.apps.googleusercontent.com&code_challenge=-BqTqiuiRm7bqR18AN6dGJza3m1LQ-rUHm5iCKVp4L0&code_challenge_method=S256&continue=https://accounts.google.com/signin/oauth/legacy/consent?authuser%3Dunknown%26part%3DAJi8hAMZa_8Uw5oeDBf5xu6LwYAzWw0IfUPVe1s-RdkhKrryVpbj5DP1e4mWD5shlmUM9MHWJjmo0221X4Itv9m_QVhq6U9AE3ixMmFjYikcxLUbYpSC0ZK2Gpk6iXyS0bmfDCxbgrB-XxcJUfHUxxHIFAIPwGNH1HXRh9xm5nfY7qXSpWvjqH2SjPsvABBxBfb4_gYQVYDpFH6xY9tvo9ivQm607DUHzOGvM91l2PAzDK2xfzP1ly9SqBaA342VjnFJmEA5mKtYKfGXiThy7J62jQMM-NK8pjUTysf6PXNOLLyUCtl7VjQPs23EBAwe_22hjTKkVo9DRb9dlv8VNmIdVK-BVUoalJmfsRs3BVet5yibkeKV20QKQlCDgtPLCorYZyu3gnFh5taleK1WBbKLyRtscF3rVnhCkIp4Y4y3m8nHtTr_lKRi0jyIfZA9CJwxJnFJa50DFwjmiOYWosdQsuu6HCZ5CfNfCorlKIYB3zjmMRcFHdwYxPvydfXXR6OPui9JQfcE4KEjnE4sDQbB6B70TD9R-o7lqt5V9Qh0cOWwKpO0-m167gCqNjS67_qgdM4UK0vGResYyPoAGae9OtyQmCAj_dcU-62wi6Keit4fc9kh4tW_Sg3z-_uWIv3YDam93LJE%26flowName%3DGeneralOAuthFlow%26as%3DS-1308179881%253A1776176817375272%26client_id%3D225787180325-cesuikl95j1qg3aijh4j10n8n7usf65p.apps.googleusercontent.com%23&dsh=S-1308179881:1776176817375272&flowName=GeneralOAuthLite&o2v=2&opparams=%253F&rart=ANgoxceTKJUOKU3v1dqvsh5ic8iCtLsW59TMJ-d2ocODSbnDkgr7QsEsYbgBLhyzyxlCKvCeKxeWXE4oh50Ic8Zii472KjuAo007j0Fg5aey109I_iJ5RME&redirect_uri=https://o.alogins.net/api/auth/callback&response_type=code&scope=openid+email+profile&service=lso&state=8jVx7mQ1bRb8HljdpHf35
- generic [ref=e31]:
- button "Next" [ref=e33]
- link "Create account" [ref=e35] [cursor=pointer]:
- /url: /lifecycle/flows/signup?app_domain=https://o.alogins.net&client_id=225787180325-cesuikl95j1qg3aijh4j10n8n7usf65p.apps.googleusercontent.com&code_challenge=-BqTqiuiRm7bqR18AN6dGJza3m1LQ-rUHm5iCKVp4L0&code_challenge_method=S256&continue=https://accounts.google.com/signin/oauth/legacy/consent?authuser%3Dunknown%26part%3DAJi8hAMZa_8Uw5oeDBf5xu6LwYAzWw0IfUPVe1s-RdkhKrryVpbj5DP1e4mWD5shlmUM9MHWJjmo0221X4Itv9m_QVhq6U9AE3ixMmFjYikcxLUbYpSC0ZK2Gpk6iXyS0bmfDCxbgrB-XxcJUfHUxxHIFAIPwGNH1HXRh9xm5nfY7qXSpWvjqH2SjPsvABBxBfb4_gYQVYDpFH6xY9tvo9ivQm607DUHzOGvM91l2PAzDK2xfzP1ly9SqBaA342VjnFJmEA5mKtYKfGXiThy7J62jQMM-NK8pjUTysf6PXNOLLyUCtl7VjQPs23EBAwe_22hjTKkVo9DRb9dlv8VNmIdVK-BVUoalJmfsRs3BVet5yibkeKV20QKQlCDgtPLCorYZyu3gnFh5taleK1WBbKLyRtscF3rVnhCkIp4Y4y3m8nHtTr_lKRi0jyIfZA9CJwxJnFJa50DFwjmiOYWosdQsuu6HCZ5CfNfCorlKIYB3zjmMRcFHdwYxPvydfXXR6OPui9JQfcE4KEjnE4sDQbB6B70TD9R-o7lqt5V9Qh0cOWwKpO0-m167gCqNjS67_qgdM4UK0vGResYyPoAGae9OtyQmCAj_dcU-62wi6Keit4fc9kh4tW_Sg3z-_uWIv3YDam93LJE%26flowName%3DGeneralOAuthFlow%26as%3DS-1308179881%253A1776176817375272%26client_id%3D225787180325-cesuikl95j1qg3aijh4j10n8n7usf65p.apps.googleusercontent.com%23&dsh=S-1308179881:1776176817375272&flowEntry=SignUp&flowName=GlifWebSignIn&o2v=2&opparams=%253F&rart=ANgoxceTKJUOKU3v1dqvsh5ic8iCtLsW59TMJ-d2ocODSbnDkgr7QsEsYbgBLhyzyxlCKvCeKxeWXE4oh50Ic8Zii472KjuAo007j0Fg5aey109I_iJ5RME&redirect_uri=https://o.alogins.net/api/auth/callback&response_type=code&scope=openid+email+profile&service=lso&signInUrl=https://accounts.google.com/signin/oauth?app_domain%3Dhttps://o.alogins.net%26client_id%3D225787180325-cesuikl95j1qg3aijh4j10n8n7usf65p.apps.googleusercontent.com%26code_challenge%3D-BqTqiuiRm7bqR18AN6dGJza3m1LQ-rUHm5iCKVp4L0%26code_challenge_method%3DS256%26continue%3Dhttps://accounts.google.com/signin/oauth/legacy/consent?authuser%253Dunknown%2526part%253DAJi8hAMZa_8Uw5oeDBf5xu6LwYAzWw0IfUPVe1s-RdkhKrryVpbj5DP1e4mWD5shlmUM9MHWJjmo0221X4Itv9m_QVhq6U9AE3ixMmFjYikcxLUbYpSC0ZK2Gpk6iXyS0bmfDCxbgrB-XxcJUfHUxxHIFAIPwGNH1HXRh9xm5nfY7qXSpWvjqH2SjPsvABBxBfb4_gYQVYDpFH6xY9tvo9ivQm607DUHzOGvM91l2PAzDK2xfzP1ly9SqBaA342VjnFJmEA5mKtYKfGXiThy7J62jQMM-NK8pjUTysf6PXNOLLyUCtl7VjQPs23EBAwe_22hjTKkVo9DRb9dlv8VNmIdVK-BVUoalJmfsRs3BVet5yibkeKV20QKQlCDgtPLCorYZyu3gnFh5taleK1WBbKLyRtscF3rVnhCkIp4Y4y3m8nHtTr_lKRi0jyIfZA9CJwxJnFJa50DFwjmiOYWosdQsuu6HCZ5CfNfCorlKIYB3zjmMRcFHdwYxPvydfXXR6OPui9JQfcE4KEjnE4sDQbB6B70TD9R-o7lqt5V9Qh0cOWwKpO0-m167gCqNjS67_qgdM4UK0vGResYyPoAGae9OtyQmCAj_dcU-62wi6Keit4fc9kh4tW_Sg3z-_uWIv3YDam93LJE%2526flowName%253DGeneralOAuthFlow%2526as%253DS-1308179881%25253A1776176817375272%2526client_id%253D225787180325-cesuikl95j1qg3aijh4j10n8n7usf65p.apps.googleusercontent.com%2523%26dsh%3DS-1308179881:1776176817375272%26flowName%3DGeneralOAuthLite%26o2v%3D2%26opparams%3D%25253F%26rart%3DANgoxceTKJUOKU3v1dqvsh5ic8iCtLsW59TMJ-d2ocODSbnDkgr7QsEsYbgBLhyzyxlCKvCeKxeWXE4oh50Ic8Zii472KjuAo007j0Fg5aey109I_iJ5RME%26redirect_uri%3Dhttps://o.alogins.net/api/auth/callback%26response_type%3Dcode%26scope%3Dopenid%2Bemail%2Bprofile%26service%3Dlso%26state%3D8jVx7mQ1bRb8HljdpHf35&state=8jVx7mQ1bRb8HljdpHf35
- contentinfo [ref=e36]:
- combobox [ref=e39] [cursor=pointer]:
- option "Afrikaans"
- option "azərbaycan"
- option "bosanski"
- option "català"
- option "Čeština"
- option "Cymraeg"
- option "Dansk"
- option "Deutsch"
- option "eesti"
- option "English (United Kingdom)"
- option "English (United States)" [selected]
- option "Español (España)"
- option "Español (Latinoamérica)"
- option "euskara"
- option "Filipino"
- option "Français (Canada)"
- option "Français (France)"
- option "Gaeilge"
- option "galego"
- option "Hrvatski"
- option "Indonesia"
- option "isiZulu"
- option "íslenska"
- option "Italiano"
- option "Kiswahili"
- option "latviešu"
- option "lietuvių"
- option "magyar"
- option "Melayu"
- option "Nederlands"
- option "norsk"
- option "ozbek"
- option "polski"
- option "Português (Brasil)"
- option "Português (Portugal)"
- option "română"
- option "shqip"
- option "Slovenčina"
- option "slovenščina"
- option "srpski (latinica)"
- option "Suomi"
- option "Svenska"
- option "Tiếng Việt"
- option "Türkçe"
- option "Ελληνικά"
- option "беларуская"
- option "български"
- option "кыргызча"
- option "қазақ тілі"
- option "македонски"
- option "монгол"
- option "Русский"
- option "српски (ћирилица)"
- option "Українська"
- option "ქართული"
- option "հայերեն"
- option "‫עברית‬‎"
- option "‫اردو‬‎"
- option "‫العربية‬‎"
- option "‫فارسی‬‎"
- option "አማርኛ"
- option "नेपाली"
- option "मराठी"
- option "हिन्दी"
- option "অসমীয়া"
- option "বাংলা"
- option "ਪੰਜਾਬੀ"
- option "ગુજરાતી"
- option "ଓଡ଼ିଆ"
- option "தமிழ்"
- option "తెలుగు"
- option "ಕನ್ನಡ"
- option "മലയാളം"
- option "සිංහල"
- option "ไทย"
- option "ລາວ"
- option "မြန်မာ"
- option "ខ្មែរ"
- option "한국어"
- option "中文(香港)"
- option "日本語"
- option "简体中文"
- option "繁體中文"
- list [ref=e40]:
- listitem [ref=e41]:
- link "Help" [ref=e42] [cursor=pointer]:
- /url: https://support.google.com/accounts?hl=en-US&p=account_iph
- listitem [ref=e43]:
- link "Privacy" [ref=e44] [cursor=pointer]:
- /url: https://accounts.google.com/TOS?loc=LV&hl=en-US&privacy=true
- listitem [ref=e45]:
- link "Terms" [ref=e46] [cursor=pointer]:
- /url: https://accounts.google.com/TOS?loc=LV&hl=en-US

View File

@@ -0,0 +1,16 @@
- main [ref=e2]:
- generic [ref=e3]:
- heading "oO" [level=1] [ref=e4]
- paragraph [ref=e5]: one tip. right now.
- link "Continue with Google" [ref=e7] [cursor=pointer]:
- /url: /api/auth/login?redirectTo=/connect
- img [ref=e8]
- text: Continue with Google
- paragraph [ref=e13]:
- text: By continuing you agree to our
- link "Terms" [ref=e14] [cursor=pointer]:
- /url: /legal/terms
- text: and
- link "Privacy Policy" [ref=e15] [cursor=pointer]:
- /url: /legal/privacy
- text: .

View File

@@ -0,0 +1,16 @@
- main [ref=e2]:
- generic [ref=e3]:
- heading "oO" [level=1] [ref=e4]
- paragraph [ref=e5]: one tip. right now.
- link "Continue with Google" [ref=e7] [cursor=pointer]:
- /url: /api/auth/login?redirectTo=/connect
- img [ref=e8]
- text: Continue with Google
- paragraph [ref=e13]:
- text: By continuing you agree to our
- link "Terms" [ref=e14] [cursor=pointer]:
- /url: /legal/terms
- text: and
- link "Privacy Policy" [ref=e15] [cursor=pointer]:
- /url: /legal/privacy
- text: .

View File

@@ -2,7 +2,7 @@
import { useEffect, useState, useCallback } from 'react';
import { useSearchParams } from 'next/navigation';
import { getIntegrations, disconnectIntegration } from '@/lib/api';
import { getIntegrations, disconnectIntegration, deleteAccount, logout } from '@/lib/api';
import type { Integration } from '@oo/shared-types';
import { Suspense } from 'react';
@@ -11,6 +11,7 @@ function ConnectPageInner() {
const [integrations, setIntegrations] = useState<Integration[]>([]);
const [loading, setLoading] = useState(true);
const [disconnecting, setDisconnecting] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const load = useCallback(async () => {
const { integrations: list } = await getIntegrations();
@@ -26,6 +27,14 @@ function ConnectPageInner() {
const isConnected = (provider: string) =>
integrations.some((i) => i.provider === provider && i.status === 'connected');
const handleDeleteAccount = async () => {
if (!confirm('Delete your account? This cannot be undone.')) return;
setDeleting(true);
await deleteAccount();
await logout();
window.location.href = '/sign-in';
};
const handleDisconnect = async (provider: string) => {
setDisconnecting(provider);
await disconnectIntegration(provider);
@@ -140,6 +149,23 @@ function ConnectPageInner() {
</a>
</div>
)}
<div style={{ marginTop: '4rem', borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: '2rem' }}>
<button
onClick={handleDeleteAccount}
disabled={deleting}
style={{
background: 'transparent',
border: 'none',
color: 'rgba(255,255,255,0.2)',
fontSize: '0.8rem',
cursor: 'pointer',
padding: 0,
}}
>
{deleting ? 'Deleting…' : 'Delete account'}
</button>
</div>
</main>
);
}

View File

@@ -0,0 +1,41 @@
export default function Privacy() {
return (
<main style={{ maxWidth: '640px', margin: '0 auto', padding: '4rem 2rem', lineHeight: 1.7 }}>
<h1 style={{ fontSize: '1.5rem', fontWeight: 300, marginBottom: '2rem', letterSpacing: '-0.02em' }}>Privacy Policy</h1>
<p style={{ color: 'rgba(255,255,255,0.5)', fontSize: '0.8rem', marginBottom: '2.5rem' }}>Effective: 1 April 2026</p>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 500, marginBottom: '0.75rem' }}>What we collect</h2>
<ul style={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.9rem', paddingLeft: '1.25rem' }}>
<li style={{ marginBottom: '0.5rem' }}>Your Google account email, name, and profile picture to identify you.</li>
<li style={{ marginBottom: '0.5rem' }}>OAuth tokens for integrations you explicitly connect.</li>
<li style={{ marginBottom: '0.5rem' }}>Your reactions to tips (done / snooze / dismiss) to improve recommendations.</li>
</ul>
</section>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 500, marginBottom: '0.75rem' }}>What we don't collect</h2>
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.9rem' }}>
We do not copy your tasks, calendar events, or any third-party app content into our database. Data is fetched on demand and held in memory for at most 30 seconds.
</p>
</section>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 500, marginBottom: '0.75rem' }}>How we use it</h2>
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.9rem' }}>
Solely to operate the recommendation engine. We do not sell data, share it with third parties, or use it for advertising.
</p>
</section>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 500, marginBottom: '0.75rem' }}>Your rights</h2>
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.9rem' }}>
You can disconnect any integration at any time from the Connect page. You can delete your account, which permanently purges all stored data. Contact the owner for data export requests.
</p>
</section>
<a href="/sign-in" style={{ color: 'rgba(255,255,255,0.35)', fontSize: '0.8rem' }}> Back</a>
</main>
);
}

View File

@@ -0,0 +1,39 @@
export default function Terms() {
return (
<main style={{ maxWidth: '640px', margin: '0 auto', padding: '4rem 2rem', lineHeight: 1.7 }}>
<h1 style={{ fontSize: '1.5rem', fontWeight: 300, marginBottom: '2rem', letterSpacing: '-0.02em' }}>Terms of Service</h1>
<p style={{ color: 'rgba(255,255,255,0.5)', fontSize: '0.8rem', marginBottom: '2.5rem' }}>Effective: 1 April 2026</p>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 500, marginBottom: '0.75rem' }}>1. The service</h2>
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.9rem' }}>
oO is a personal recommendation system. It reads signals from apps you connect and surfaces one tip at a time. The service is provided as-is during the prototype phase.
</p>
</section>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 500, marginBottom: '0.75rem' }}>2. Your data</h2>
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.9rem' }}>
We store OAuth tokens for integrations you connect. We fetch your tasks on demand we do not copy or cache raw data beyond a 30-second in-memory buffer. You can revoke access or delete your account at any time.
</p>
</section>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 500, marginBottom: '0.75rem' }}>3. Account deletion</h2>
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.9rem' }}>
Deleting your account revokes all integration tokens, purges your feedback history, and anonymises your identity record. No data is retained in identifiable form.
</p>
</section>
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 500, marginBottom: '0.75rem' }}>4. Limitations</h2>
<p style={{ color: 'rgba(255,255,255,0.7)', fontSize: '0.9rem' }}>
This is a prototype. We make no uptime guarantees. The service may change or be discontinued with reasonable notice.
</p>
</section>
<a href="/sign-in" style={{ color: 'rgba(255,255,255,0.35)', fontSize: '0.8rem' }}> Back</a>
</main>
);
}

View File

@@ -88,6 +88,7 @@ export default function TipPage() {
return (
<>
<style>{`
@keyframes breathe {
0%, 100% { opacity: 0.3; }
@@ -100,12 +101,11 @@ export default function TipPage() {
onPointerUp={onPointerUp}
onPointerLeave={onPointerUp}
style={{
minHeight: '100vh',
height: '100dvh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '3rem 2rem',
userSelect: 'none',
WebkitUserSelect: 'none',
cursor: state === 'tip' ? 'default' : 'auto',
@@ -127,7 +127,7 @@ export default function TipPage() {
{/* Loading label */}
{(state === 'loading' || state === 'done') && (
<p style={{
marginTop: '1.25rem',
margin: 0,
color: 'rgba(255,255,255,0.55)',
fontSize: '0.7rem',
letterSpacing: '0.18em',
@@ -140,7 +140,7 @@ export default function TipPage() {
{/* Tip */}
{(state === 'tip' || state === 'actions') && tip && (
<Fade visible={visible && state !== 'actions'} style={{ textAlign: 'center', maxWidth: '420px' }}>
<Fade visible={visible && state !== 'actions'} style={{ textAlign: 'center', maxWidth: '420px', padding: '0 2rem' }}>
<p style={{
fontSize: 'clamp(1.25rem, 4vw, 1.75rem)',
fontWeight: 300,

File diff suppressed because one or more lines are too long

View File

@@ -31,6 +31,14 @@ export const tipFeedback = sqliteTable('tip_feedback', {
createdAt: text('created_at').notNull(),
});
// Each row = one tip served. Join with tipFeedback on tipId to compute reaction rate + dwell.
export const tipViews = sqliteTable('tip_views', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
tipId: text('tip_id').notNull(),
servedAt: text('served_at').notNull(),
});
export const sessions = sqliteTable('sessions', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),

View File

@@ -103,7 +103,7 @@ router.get('/callback', async (req: Request, res: Response) => {
if (!user) {
const id = nanoid();
await db.insert(users).values({ id, email, name, image, googleId, createdAt: now });
await db.insert(users).values({ id, email, name, image, googleId, createdAt: now, consentGiven: true, consentAt: now });
[user] = await db.select().from(users).where(eq(users.id, id)).limit(1);
}

View File

@@ -1,7 +1,7 @@
import { type Router as ExpressRouter, Router, Response } from 'express';
import { nanoid } from 'nanoid';
import { db } from '../db/index.js';
import { integrationTokens, tipFeedback } from '../db/schema.js';
import { integrationTokens, tipFeedback, tipViews } from '../db/schema.js';
import { eq, and } from 'drizzle-orm';
import { requireAuth, AuthenticatedRequest } from '../middleware/session.js';
import type { Tip } from '@oo/shared-types';
@@ -72,6 +72,14 @@ router.post('/recommend', requireAuth, async (req: AuthenticatedRequest, res: Re
return;
}
// Record metric: tip served
await db.insert(tipViews).values({
id: nanoid(),
userId: req.userId!,
tipId: tip.id,
servedAt: new Date().toISOString(),
});
res.json({ tip });
});

View File

@@ -1,6 +1,6 @@
import { type Router as ExpressRouter, Router, Response } from 'express';
import { db } from '../db/index.js';
import { users, integrationTokens, tipFeedback, sessions } from '../db/schema.js';
import { users, integrationTokens, tipFeedback, tipViews, sessions } from '../db/schema.js';
import { eq } from 'drizzle-orm';
import { requireAuth, AuthenticatedRequest } from '../middleware/session.js';
@@ -54,6 +54,7 @@ router.delete('/me', requireAuth, async (req: AuthenticatedRequest, res: Respons
// Delete cascade
await db.delete(integrationTokens).where(eq(integrationTokens.userId, userId));
await db.delete(tipFeedback).where(eq(tipFeedback.userId, userId));
await db.delete(tipViews).where(eq(tipViews.userId, userId));
await db.delete(sessions).where(eq(sessions.userId, userId));
// Soft-delete user (GDPR: keep audit trail row without PII)