Compare commits

...

27 Commits

Author SHA1 Message Date
SeekingGamer
f3ee755f47 fix(docs): Update Readme.md
All checks were successful
Deploy to talkpro / build-and-sync (push) Successful in 29s
2026-05-21 10:19:30 +08:00
SeekingGamer
f8d97f46c5 Update Base.astro with new js
All checks were successful
Deploy to talkpro / build-and-sync (push) Successful in 31s
2026-05-21 10:15:47 +08:00
SeekingGamer
edec5370b6 fix(style): Change IOS buttons style
Some checks failed
Deploy to talkpro / build-and-sync (push) Has been cancelled
2026-05-20 16:17:12 +08:00
08699e6d0d Merge pull request 'fix(style): fix gaps and pop up screen ui' (#13) from finn-staging into main
All checks were successful
Deploy to talkpro / build-and-sync (push) Successful in 35s
Reviewed-on: #13
2026-05-20 06:37:35 +00:00
SeekingGamer
74793fbc11 fix(style): fix gaps and pop up screen ui 2026-05-20 11:36:04 +08:00
1de7b09ceb Merge pull request 'fix(docs): Update Translation ts' (#12) from finn-staging into main
All checks were successful
Deploy to talkpro / build-and-sync (push) Successful in 28s
Reviewed-on: #12
2026-05-19 03:31:02 +00:00
SeekingGamer
a8229e543e fix(docs): Update Translation ts 2026-05-19 11:30:10 +08:00
b34e4b8538 Merge pull request 'fix(style):Removed the site link in Downloads astro' (#11) from finn-staging into main
All checks were successful
Deploy to talkpro / build-and-sync (push) Successful in 29s
Reviewed-on: #11
2026-05-19 03:00:49 +00:00
SeekingGamer
37055ca74a fix(style):Removed the site link in Downloads astro 2026-05-19 10:59:27 +08:00
9f58001a56 Merge pull request 'finn-staging' (#10) from finn-staging into main
All checks were successful
Deploy to talkpro / build-and-sync (push) Successful in 33s
Reviewed-on: #10
2026-05-19 02:55:57 +00:00
SeekingGamer
2a2d5fb9e5 fix(feat): Change styling for responsive layout 2026-05-19 10:51:38 +08:00
SeekingGamer
0220aa5ff8 fix(style): compact reliability row at 1024 2026-05-18 16:03:09 +08:00
SeekingGamer
7f7b44415e fix(style): keep reliability grid at tablet widths 2026-05-18 15:58:05 +08:00
265a867602 Merge pull request 'fix(asset): bust cached public assets' (#9) from finn-staging into main
All checks were successful
Deploy to talkpro / build-and-sync (push) Successful in 37s
Reviewed-on: #9
2026-05-18 07:42:03 +00:00
SeekingGamer
61ef7c14de fix(asset): bust cached public assets 2026-05-18 15:39:38 +08:00
b8103ea072 1
All checks were successful
Deploy to talkpro / build-and-sync (push) Successful in 27s
2026-05-18 15:23:09 +08:00
a481c382a6 CI deploy without sudo: tar over ssh when rsync missing
All checks were successful
Deploy to talkpro / build-and-sync (push) Successful in 33s
Remove apt/sudo install step (act runners lack sudo). Use rsync when
available; otherwise clear remote web root and stream dist/ via tar|ssh.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 15:19:16 +08:00
5f9e8db7e5 CI: install rsync and openssh-client before deploy
Some checks failed
Deploy to talkpro / build-and-sync (push) Failing after 11s
Gitea/act runners often lack rsync; apt/apk/dnf install step runs
before ssh/rsync in deploy-talkpro.sh.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 15:17:13 +08:00
fe14ca30ff Fix deploy script: use SSH_OPTS array for ssh mkdir
Some checks failed
Deploy to talkpro / build-and-sync (push) Failing after 31s
Typo SSH[@] left ssh out of the command so the shell tried to run
ubuntu@host as a program (exit 127).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 15:15:31 +08:00
cf9cbb6134 Fix CI: remove optional secret overrides step
Some checks failed
Deploy to talkpro / build-and-sync (push) Failing after 31s
Empty optional secrets made [ -n ... ] && echo fail under set -e.
Job env already defines host, user, and deploy path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 15:13:48 +08:00
77485eee63 Fix CI deploy: default talkpro host, require only SSH secret
Some checks failed
Deploy to talkpro / build-and-sync (push) Failing after 14s
Gitea workflow no longer needs TALKPRO_HOST secret; defaults match
talkpro VPS. Fail fast with a clear message if TALKPRO_SSH_PRIVATE_KEY
is missing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 15:06:14 +08:00
6e90a4adb6 Add Gitea Actions deploy workflow for talkpro.info
Some checks failed
Deploy to talkpro / build-and-sync (push) Failing after 18s
Build Astro on push to main/master and rsync dist/ to the marketing
server via SSH. Includes deploy-talkpro.sh, env example, and README.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 15:01:58 +08:00
3933cf42c0 1 2026-05-18 14:58:09 +08:00
df6cff4895 Merge pull request 'fix(style): Changes responsive layout breakpoint' (#8) from finn-staging into main
Reviewed-on: #8
2026-05-18 06:41:21 +00:00
SeekingGamer
7b45ca94a6 fix(style): Changes responsive layout breakpoint 2026-05-18 14:39:56 +08:00
82e3a23df3 Merge pull request 'fix(feat): Refine responsive layout and section spacing' (#7) from finn-staging into main
Reviewed-on: #7
2026-05-15 09:46:01 +00:00
SeekingGamer
c09ba76350 fix(feat): Refine responsive layout and section spacing 2026-05-15 17:44:31 +08:00
36 changed files with 2708 additions and 347 deletions

10
.env.deploy.example Normal file
View File

@@ -0,0 +1,10 @@
# Copy to .env.deploy for local runs: set -a && source .env.deploy && set +a && ./scripts/deploy-talkpro.sh
#
# Gitea Actions: only this secret is required (Settings → Secrets → Actions):
# TALKPRO_SSH_PRIVATE_KEY = full PEM file contents
# Host/user/path defaults are in .gitea/workflows/deploy-talkpro.yml
TALKPRO_HOST=13.214.179.69
TALKPRO_USER=ubuntu
TALKPRO_REMOTE_ROOT=/home/ubuntu/talkpro
TALKPRO_SSH_KEY_FILE=/absolute/path/to/luis-only.pem

View File

@@ -0,0 +1,57 @@
# Build talk-pro and rsync dist/ to the marketing VPS (talkpro.info).
#
# Required Gitea repo secret (Settings → Secrets → Actions):
# TALKPRO_SSH_PRIVATE_KEY full PEM for ubuntu@talkpro (same as luis-only.pem)
#
# Host/user/path defaults are in the `env:` block below (edit there if needed).
#
# Requires a runner with: node 22+, ssh, ssh-keyscan, tar (rsync optional; no sudo needed).
name: Deploy to talkpro
on:
push:
branches:
- main
- master
workflow_dispatch:
env:
TALKPRO_HOST: "13.214.179.69"
TALKPRO_USER: ubuntu
TALKPRO_REMOTE_ROOT: /home/ubuntu/talkpro
jobs:
build-and-sync:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Check deploy secrets
env:
TALKPRO_SSH_PRIVATE_KEY: ${{ secrets.TALKPRO_SSH_PRIVATE_KEY }}
run: |
if [ -z "${TALKPRO_SSH_PRIVATE_KEY}" ]; then
echo "ERROR: Missing Gitea secret TALKPRO_SSH_PRIVATE_KEY"
echo "Add it under Repository → Settings → Secrets (Actions)."
echo "Value: full contents of your ubuntu@talkpro SSH private key (PEM)."
exit 1
fi
- name: Trust host key
run: |
mkdir -p ~/.ssh
chmod 700 ~/.ssh
ssh-keyscan -H "$TALKPRO_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Build and deploy to talkpro
env:
TALKPRO_SSH_PRIVATE_KEY: ${{ secrets.TALKPRO_SSH_PRIVATE_KEY }}
run: bash scripts/deploy-talkpro.sh

2
.gitignore vendored
View File

@@ -5,3 +5,5 @@ dist/
.env .env
.env.* .env.*
!.env.example !.env.example
!.env.deploy.example
.env.deploy

View File

@@ -26,6 +26,28 @@ Open [http://localhost:4321](http://localhost:4321)
| `npm run build` | Build for production into `dist/` | | `npm run build` | Build for production into `dist/` |
| `npm run preview` | Preview production build locally | | `npm run preview` | Preview production build locally |
## Deploy (Gitea Actions → talkpro.info)
Pushes to **`main`** or **`master`** run [`.gitea/workflows/deploy-talkpro.yml`](.gitea/workflows/deploy-talkpro.yml): `npm ci``npm run build``rsync` `dist/` to the marketing server (`/home/ubuntu/talkpro`).
**Repository secret** (Gitea → Settings → Secrets → Actions) — **required**:
| Secret | Value |
|--------|--------|
| `TALKPRO_SSH_PRIVATE_KEY` | Full PEM text of the `ubuntu@talkpro` deploy key |
Host (`13.214.179.69`), user (`ubuntu`), and web root (`/home/ubuntu/talkpro`) are set in the workflow. Optional secrets `TALKPRO_HOST`, `TALKPRO_USER`, `TALKPRO_REMOTE_ROOT` override those defaults.
Manual deploy from this repo:
```bash
cp .env.deploy.example .env.deploy # edit paths
set -a && source .env.deploy && set +a
bash scripts/deploy-talkpro.sh
```
`/api/site-links` (APK / App Store URLs) is still updated via the parent **talkpro** repo: `./scripts/post-talkpro-site-links.sh` on your laptop.
## Project Structure ## Project Structure
``` ```
@@ -72,6 +94,41 @@ Figma assets are stored in `public/assets/`. To re-download them (valid for 7 da
bash scripts/download-assets.sh bash scripts/download-assets.sh
``` ```
## Cloudflare Cache & the `site-links-client.js` Version Flag
The site is proxied through **Cloudflare**, which aggressively caches static files. Astro's built CSS/JS bundles are safe because they get **content-hashed filenames** on every build (e.g. `_astro/index.Bx3kF9.js`) — Cloudflare never has an old copy because the filename itself changes.
`public/site-links-client.js` is the exception. It lives in `public/` so it always deploys to the same URL (`/site-links-client.js`). Cloudflare caches this URL and will keep serving the old version until either:
- The Cloudflare cache is **purged** (Caching → Purge Everything in the dashboard), or
- The script URL is **versioned** so Cloudflare treats it as a new file.
The URL is versioned in `src/layouts/Base.astro`:
```html
<script src="/site-links-client.js?v=2" defer></script>
```
**Every time you change `site-links-client.js`**, bump this number (`?v=2``?v=3`, etc.) and redeploy. Cloudflare will fetch the latest file immediately without needing a cache purge.
| Change type | Cache action needed |
|---|---|
| CSS / Astro component change | None — hashed filename handles it |
| `site-links-client.js` change | Bump `?v=N` in `Base.astro` and redeploy |
| Emergency full reset | Cloudflare dashboard → Caching → Purge Everything |
## i18n — Adding or Updating Translations
All page copy lives in `src/i18n/translations.ts`. The English (`en`) entry is the base — every other language only needs to override the keys it wants to change; missing keys fall back to English automatically.
Supported languages: `en`, `zh-cn`, `zh-tw`, `es`, `vi`, `pt`, `de`, `fr`, `hi`, `ar`, `ru`, `id`, `ur`, `ja`, `ko`, `ms`.
To add a new language:
1. Add the locale code to the `languages` array at the top of `translations.ts`.
2. Add its label to `languageLabels` and `languageNames`.
3. Add a translation object under `translations` (only the keys you want to override are required).
4. Create the page route: copy `src/pages/[lang]/index.astro` if it doesn't exist.
## Design Source ## Design Source
Figma: [Talk Pro — Home Page Desktop](https://www.figma.com/design/Gb8WMJ2RLlcZ0bigoiOQx9/Talk-Pro?node-id=9505-537&m=dev) Figma: [Talk Pro — Home Page Desktop](https://www.figma.com/design/Gb8WMJ2RLlcZ0bigoiOQx9/Talk-Pro?node-id=9505-537&m=dev)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 716 KiB

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 402 KiB

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 602 KiB

After

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 928 KiB

After

Width:  |  Height:  |  Size: 473 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 848 KiB

After

Width:  |  Height:  |  Size: 880 KiB

Binary file not shown.

310
public/site-links-client.js Normal file
View File

@@ -0,0 +1,310 @@
/**
* Mirrors talkpro static site: /api/site-links, APK default, in-app + App Store modals.
* Strings come from #site-links-i18n (JSON) rendered by DownloadCTA.astro.
*/
(function () {
const DEFAULT_APK = 'https://talkspro.xyz/download';
let apkDownloadUrl = DEFAULT_APK;
function getApkDownloadUrl() {
return apkDownloadUrl;
}
function getI18n() {
const el = document.getElementById('site-links-i18n');
if (!el || !el.textContent.trim()) return {};
try {
return JSON.parse(el.textContent);
} catch {
return {};
}
}
function detectInAppBrowser() {
const ua = navigator.userAgent || '';
if (/MicroMessenger/i.test(ua)) return 'wechat';
try {
if (typeof window.WeixinJSBridge !== 'undefined') return 'wechat';
} catch {
/* ignore */
}
if (/QQ\//i.test(ua) && /MQQBrowser/i.test(ua)) return 'qq';
if (/Weibo/i.test(ua)) return 'weibo';
return '';
}
function isIOSDevice() {
const ua = navigator.userAgent || '';
if (/iPhone|iPad|iPod/i.test(ua)) return true;
if (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) return true;
return false;
}
function isAndroidDevice() {
const ua = navigator.userAgent || '';
return /Android/i.test(ua) && !isIOSDevice();
}
const MODAL_WRAP =
'position:fixed;inset:0;z-index:10050;display:flex;align-items:center;justify-content:center;padding:16px;box-sizing:border-box;';
const MODAL_BACK = 'position:absolute;inset:0;background:rgba(0,0,0,.45);cursor:pointer;';
const MODAL_PANEL =
'position:relative;max-width:420px;width:100%;margin:auto;padding:20px 22px;border-radius:12px;background:#fff;color:#1a1a1a;font:15px/1.45 system-ui,-apple-system,sans-serif;box-shadow:0 12px 40px rgba(0,0,0,.18);';
const MODAL_TITLE = 'margin:0 0 10px;font-size:18px;font-weight:700;line-height:1.25;';
const MODAL_BODY = 'margin:0 0 18px;font-size:14px;color:#444;';
const MODAL_ACTIONS = 'display:flex;flex-wrap:wrap;gap:10px;justify-content:flex-end;';
const BTN =
'cursor:pointer;border-radius:8px;padding:10px 14px;font-size:14px;font-weight:600;border:1px solid #ccc;background:#f5f5f5;color:#1a1a1a;';
const BTN_PRIMARY = 'border-color:#1a6cff;background:#1a6cff;color:#fff;';
const BTN_ORANGE = 'cursor:pointer;border-radius:14px;padding:10px 14px;font-size:14px;font-weight:600;border:1px solid #f28a4b;background:#f28a4b;color:#fff;';
function initInAppBrowserModal() {
if (document.getElementById('inapp-browser-modal')) return;
const wrap = document.createElement('div');
wrap.id = 'inapp-browser-modal';
wrap.setAttribute('role', 'dialog');
wrap.setAttribute('aria-modal', 'true');
wrap.setAttribute('aria-hidden', 'true');
wrap.style.cssText = MODAL_WRAP + 'display:none;';
wrap.innerHTML =
'<div data-inapp-close style="' +
MODAL_BACK +
'"></div>' +
'<div style="' +
MODAL_PANEL +
'">' +
'<h3 id="inapp-modal-title" style="' +
MODAL_TITLE +
'"></h3>' +
'<p id="inapp-modal-body" style="' +
MODAL_BODY +
'"></p>' +
'<div style="' +
MODAL_ACTIONS +
'">' +
'<button type="button" id="inapp-copy-apk" style="' +
BTN +
BTN_PRIMARY +
'"></button>' +
'<button type="button" id="inapp-try-chrome" style="' +
BTN +
'" hidden></button>' +
'<button type="button" id="inapp-got-it" data-inapp-close style="' +
BTN +
'"></button>' +
'</div></div>';
document.body.appendChild(wrap);
const close = () => {
wrap.style.display = 'none';
wrap.setAttribute('aria-hidden', 'true');
};
wrap.querySelectorAll('[data-inapp-close]').forEach((el) => {
el.addEventListener('click', close);
});
const i18n = () => getI18n();
document.getElementById('inapp-copy-apk').addEventListener('click', async () => {
const btn = document.getElementById('inapp-copy-apk');
const dict = i18n();
const done = dict.inapp_browser_copied || 'Copied';
try {
await navigator.clipboard.writeText(getApkDownloadUrl());
const prev = btn.textContent;
btn.textContent = done;
setTimeout(() => {
btn.textContent = dict.inapp_browser_copy || prev;
}, 1600);
} catch {
window.prompt(dict.inapp_browser_copy || 'Copy', getApkDownloadUrl());
}
});
document.getElementById('inapp-try-chrome').addEventListener('click', () => {
const enc = encodeURIComponent(getApkDownloadUrl());
const intent =
getApkDownloadUrl().replace(/^https:/i, 'intent:') +
'#Intent;' +
'scheme=https;action=android.intent.action.VIEW;category=android.intent.category.BROWSABLE;' +
'package=com.android.chrome;S.browser_fallback_url=' +
enc +
';end';
window.location.href = intent;
});
}
function openInAppBrowserModal() {
const modal = document.getElementById('inapp-browser-modal');
if (!modal) return;
const dict = getI18n();
const tryChrome = modal.querySelector('#inapp-try-chrome');
const showChrome = isAndroidDevice() && !!detectInAppBrowser();
if (tryChrome) {
tryChrome.hidden = !showChrome;
tryChrome.setAttribute('aria-hidden', showChrome ? 'false' : 'true');
}
modal.style.display = 'flex';
modal.setAttribute('aria-hidden', 'false');
const titleEl = modal.querySelector('#inapp-modal-title');
const bodyEl = modal.querySelector('#inapp-modal-body');
if (titleEl) titleEl.textContent = dict.inapp_browser_title || '';
if (bodyEl) {
const iosWechat = isIOSDevice() && detectInAppBrowser() === 'wechat';
const bodyKey = iosWechat ? 'inapp_browser_body_ios' : 'inapp_browser_body';
bodyEl.innerHTML =
dict[bodyKey] || dict.inapp_browser_body || '';
}
const copyBtn = modal.querySelector('#inapp-copy-apk');
const gotBtn = modal.querySelector('#inapp-got-it');
if (copyBtn) copyBtn.textContent = dict.inapp_browser_copy || '';
if (tryChrome) tryChrome.textContent = dict.inapp_browser_try_chrome || '';
if (gotBtn) gotBtn.textContent = dict.inapp_browser_got_it || 'OK';
}
function initAppStoreSoonModal() {
if (document.getElementById('app-store-soon-modal')) return;
const wrap = document.createElement('div');
wrap.id = 'app-store-soon-modal';
wrap.setAttribute('role', 'dialog');
wrap.setAttribute('aria-modal', 'true');
wrap.setAttribute('aria-hidden', 'true');
wrap.style.cssText = MODAL_WRAP + 'display:none;';
wrap.innerHTML =
'<div data-app-soon-close style="' +
MODAL_BACK +
'"></div>' +
'<div style="' +
MODAL_PANEL +
'">' +
'<h3 id="app-soon-title" style="' +
MODAL_TITLE +
'"></h3>' +
'<p id="app-soon-body" style="' +
MODAL_BODY +
'"></p>' +
'<div style="' +
MODAL_ACTIONS +
'">' +
'<button type="button" data-app-soon-close style="' +
BTN_ORANGE +
'"></button>' +
'</div></div>';
document.body.appendChild(wrap);
const close = () => {
wrap.style.display = 'none';
wrap.setAttribute('aria-hidden', 'true');
};
wrap.querySelectorAll('[data-app-soon-close]').forEach((el) => {
el.addEventListener('click', close);
});
}
function openAppStoreSoonModal() {
const modal = document.getElementById('app-store-soon-modal');
if (!modal) return;
const dict = getI18n();
const tEl = modal.querySelector('#app-soon-title');
const bEl = modal.querySelector('#app-soon-body');
const ok = modal.querySelector('button[data-app-soon-close]');
if (tEl) tEl.textContent = dict.app_store_soon_title || '';
if (bEl) bEl.innerHTML = dict.app_store_soon_body || '';
if (ok) ok.textContent = dict.app_store_soon_ok || 'OK';
modal.style.display = 'flex';
modal.setAttribute('aria-hidden', 'false');
}
function bindBrowserDownloadLinks() {
document.querySelectorAll('a.store-badge--browser').forEach((a) => {
const openForInApp = (e) => {
if (!detectInAppBrowser()) return;
e.preventDefault();
e.stopPropagation();
openInAppBrowserModal();
};
a.addEventListener('click', openForInApp);
a.addEventListener(
'touchend',
(e) => {
if (!detectInAppBrowser()) return;
e.preventDefault();
openInAppBrowserModal();
},
{ passive: false }
);
});
}
function bindAppStoreAppleBadges() {
document.querySelectorAll('a.store-badge--apple[data-app-soon]').forEach((a) => {
const stopAndOpen = (e) => {
e.preventDefault();
e.stopPropagation();
openAppStoreSoonModal();
};
a.addEventListener('click', stopAndOpen);
a.addEventListener(
'touchend',
(e) => {
e.preventDefault();
e.stopPropagation();
openAppStoreSoonModal();
},
{ passive: false }
);
});
}
function loadSiteLinksThenBind() {
if (window.location.protocol === 'file:') {
bindBrowserDownloadLinks();
bindAppStoreAppleBadges();
return;
}
fetch('/api/site-links', { credentials: 'same-origin' })
.then((r) => {
if (!r.ok) throw new Error('bad status');
return r.json();
})
.then((j) => {
const apk = String(j.apk_download_url || '').trim();
const ios = String(j.app_store_url || '').trim();
if (apk.startsWith('https://')) apkDownloadUrl = apk;
document.querySelectorAll('a.store-badge--browser').forEach((a) => {
if (apk.startsWith('https://')) a.setAttribute('href', apk);
});
document.querySelectorAll('a.store-badge--apple').forEach((a) => {
if (ios.startsWith('https://')) {
a.setAttribute('href', ios);
a.removeAttribute('data-app-soon');
a.setAttribute('rel', 'noopener noreferrer');
a.setAttribute('target', '_blank');
}
});
const meta = document.getElementById('site-links-meta');
if (meta) {
const dict = getI18n();
const u = String(j.updated_at || '').trim();
if (u) {
meta.textContent =
(dict.linksMetaPrefix || 'Store links file updated: ') +
u +
(dict.linksMetaSuffix || ' (GMT+8)');
meta.hidden = false;
} else {
meta.hidden = true;
}
}
})
.catch(() => {
/* keep defaults */
})
.finally(() => {
bindBrowserDownloadLinks();
bindAppStoreAppleBadges();
});
}
initInAppBrowserModal();
initAppStoreSoonModal();
loadSiteLinksThenBind();
})();

71
scripts/deploy-talkpro.sh Executable file
View File

@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Build Astro dist/ and sync to the Talk Pro marketing host (talkpro.info).
# Used by Gitea Actions and for manual deploys from this repo.
#
# Required (CI): TALKPRO_SSH_PRIVATE_KEY — PEM contents (Gitea secret)
# Optional: TALKPRO_SSH_KEY_FILE — path to PEM (local)
# Defaults match .env.deploy.example / Gitea workflow job env:
# TALKPRO_HOST=13.214.179.69 TALKPRO_USER=ubuntu TALKPRO_REMOTE_ROOT=/home/ubuntu/talkpro
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
HOST="${TALKPRO_HOST:-13.214.179.69}"
USER="${TALKPRO_USER:-ubuntu}"
REMOTE_ROOT="${TALKPRO_REMOTE_ROOT:-/home/ubuntu/talkpro}"
[[ -n "$HOST" ]] || HOST=13.214.179.69
[[ -n "$USER" ]] || USER=ubuntu
[[ -n "$REMOTE_ROOT" ]] || REMOTE_ROOT=/home/ubuntu/talkpro
KEY_FILE="${TALKPRO_SSH_KEY_FILE:-}"
TMP_KEY=""
cleanup() {
[[ -n "$TMP_KEY" && -f "$TMP_KEY" ]] && rm -f "$TMP_KEY"
}
trap cleanup EXIT
if [[ -n "${TALKPRO_SSH_PRIVATE_KEY:-}" ]]; then
TMP_KEY="$(mktemp)"
chmod 600 "$TMP_KEY"
printf '%s\n' "$TALKPRO_SSH_PRIVATE_KEY" | tr -d '\r' > "$TMP_KEY"
KEY_FILE="$TMP_KEY"
elif [[ -z "$KEY_FILE" || ! -f "$KEY_FILE" ]]; then
echo "ERROR: Set TALKPRO_SSH_PRIVATE_KEY (Gitea secret / env) or TALKPRO_SSH_KEY_FILE (local path)" >&2
exit 1
fi
if [[ ! -s "$KEY_FILE" ]]; then
echo "ERROR: SSH key file is empty — check TALKPRO_SSH_PRIVATE_KEY secret in Gitea" >&2
exit 1
fi
chmod 600 "$KEY_FILE"
SSH_OPTS=(-i "$KEY_FILE" -o BatchMode=yes -o StrictHostKeyChecking=accept-new)
RSYNC_SSH="ssh -i \"$KEY_FILE\" -o BatchMode=yes -o StrictHostKeyChecking=accept-new"
REMOTE="${USER}@${HOST}"
if [[ -f package-lock.json ]]; then
npm ci
else
npm install
fi
npm run build
command -v ssh >/dev/null || { echo "ERROR: ssh not found (install openssh-client)" >&2; exit 127; }
ssh "${SSH_OPTS[@]}" "$REMOTE" "mkdir -p ${REMOTE_ROOT}"
if command -v rsync >/dev/null 2>&1; then
echo "Uploading with rsync..."
rsync -avz --delete \
-e "$RSYNC_SSH" \
dist/ \
"${REMOTE}:${REMOTE_ROOT}/"
else
command -v tar >/dev/null || { echo "ERROR: need rsync or tar on the runner" >&2; exit 127; }
echo "Uploading with tar over ssh (rsync not available on runner)..."
ssh "${SSH_OPTS[@]}" "$REMOTE" "mkdir -p ${REMOTE_ROOT} && rm -rf ${REMOTE_ROOT:?}/*"
tar -C dist -cf - . | ssh "${SSH_OPTS[@]}" "$REMOTE" "tar -C ${REMOTE_ROOT} -xf -"
fi
echo "Deployed dist/ → ${REMOTE}:${REMOTE_ROOT}/"

5
src/assets.ts Normal file
View File

@@ -0,0 +1,5 @@
const assetVersion = '20260518-1'
export function assetPath(path: string) {
return `${path}?v=${assetVersion}`
}

View File

@@ -1,4 +1,5 @@
--- ---
import { assetPath } from '../assets'
import type { Translations } from '../i18n/translations' import type { Translations } from '../i18n/translations'
export interface Props { export interface Props {
@@ -7,10 +8,12 @@ export interface Props {
const { t } = Astro.props const { t } = Astro.props
const slides = [ const slides = [
"/assets/preview-phone.png", assetPath("/assets/preview-phone.png"),
"/assets/preview-phone.png", assetPath("/assets/preview-phone.png"),
"/assets/preview-phone.png", assetPath("/assets/preview-phone.png"),
] ]
const arrowLeft = assetPath("/assets/preview-arrow-left.svg")
const arrowRight = assetPath("/assets/preview-arrow-right.svg")
--- ---
<section class="app-preview"> <section class="app-preview">
@@ -31,7 +34,7 @@ const slides = [
<div class="app-preview__control-wrap"> <div class="app-preview__control-wrap">
<div class="app-preview__control-inner"> <div class="app-preview__control-inner">
<button id="btn-prev" class="app-preview__button"> <button id="btn-prev" class="app-preview__button">
<img alt={t.previous} class="app-preview__button-icon" src="/assets/preview-arrow-left.svg" /> <img alt={t.previous} class="app-preview__button-icon" src={arrowLeft} />
</button> </button>
</div> </div>
</div> </div>
@@ -43,7 +46,7 @@ const slides = [
<div class="app-preview__control-wrap"> <div class="app-preview__control-wrap">
<div class="app-preview__control-inner"> <div class="app-preview__control-inner">
<button id="btn-next" class="app-preview__button"> <button id="btn-next" class="app-preview__button">
<img alt={t.next} class="app-preview__button-icon app-preview__button-icon--next" src="/assets/preview-arrow-right.svg" /> <img alt={t.next} class="app-preview__button-icon app-preview__button-icon--next" src={arrowRight} />
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,5 @@
--- ---
import { assetPath } from '../assets'
import type { Translations } from '../i18n/translations' import type { Translations } from '../i18n/translations'
export interface Props { export interface Props {
@@ -6,14 +7,14 @@ export interface Props {
} }
const { t } = Astro.props const { t } = Astro.props
const halftone = "/assets/core-halftone-bg.png"; const halftone = assetPath("/assets/core-halftone-bg.png");
const icons = [ const icons = [
"/assets/core-icon-private.png", assetPath("/assets/core-icon-private.png"),
"/assets/core-icon-groups.png", assetPath("/assets/core-icon-groups.png"),
"/assets/core-icon-channels.png", assetPath("/assets/core-icon-channels.png"),
"/assets/core-icon-voice.png", assetPath("/assets/core-icon-voice.png"),
"/assets/core-icon-video.png", assetPath("/assets/core-icon-video.png"),
"/assets/core-icon-media.png", assetPath("/assets/core-icon-media.png"),
] ]
--- ---

View File

@@ -1,16 +1,20 @@
--- ---
import { assetPath } from '../assets'
import type { Translations } from '../i18n/translations' import type { Translations } from '../i18n/translations'
export interface Props { export interface Props {
t: Translations['download'] t: Translations['download']
siteLinks: Translations['siteLinks']
} }
const { t } = Astro.props const { t, siteLinks } = Astro.props
const bgPattern = "/assets/cta-bg-pattern.svg"; const bgPattern = assetPath("/assets/cta-bg-pattern.svg");
const talkproLogo = "/assets/cta-talkpro-logo.svg"; const talkproLogo = assetPath("/assets/cta-talkpro-logo.svg");
const androidIcon = "/assets/cta-android-icon.svg"; const androidIcon = assetPath("/assets/cta-android-icon.svg");
const appleIcon = "/assets/cta-apple-icon.svg"; const appleIcon = assetPath("/assets/cta-apple-icon.svg");
const phoneArt = "/assets/cta-phone-art.png"; const phoneArt = assetPath("/assets/cta-phone-art.png");
const defaultApkHref = "https://talkspro.xyz/download";
const siteLinksJson = JSON.stringify(siteLinks);
--- ---
<section id="download" class="download-cta"> <section id="download" class="download-cta">
@@ -35,7 +39,12 @@ const phoneArt = "/assets/cta-phone-art.png";
</div> </div>
<div class="store-badges"> <div class="store-badges">
<div class="store-badge store-badge--android"> <a
class="store-badge store-badge--android store-badge--browser"
href={defaultApkHref}
rel="noopener noreferrer"
target="_blank"
>
<div class="store-badge__icon-frame"> <div class="store-badge__icon-frame">
<img alt={t.androidAlt} class="store-badge__android-icon" src={androidIcon} /> <img alt={t.androidAlt} class="store-badge__android-icon" src={androidIcon} />
</div> </div>
@@ -43,8 +52,12 @@ const phoneArt = "/assets/cta-phone-art.png";
<p class="store-badge__platform">{t.android}</p> <p class="store-badge__platform">{t.android}</p>
<p class="store-badge__label">{t.androidCta}</p> <p class="store-badge__label">{t.androidCta}</p>
</div> </div>
</div> </a>
<div class="store-badge store-badge--ios"> <a
class="store-badge store-badge--ios store-badge--apple"
href="#"
data-app-soon="1"
>
<div class="store-badge__icon-frame"> <div class="store-badge__icon-frame">
<img alt={t.appleAlt} class="store-badge__apple-icon" src={appleIcon} /> <img alt={t.appleAlt} class="store-badge__apple-icon" src={appleIcon} />
</div> </div>
@@ -52,8 +65,9 @@ const phoneArt = "/assets/cta-phone-art.png";
<p class="store-badge__platform">{t.ios}</p> <p class="store-badge__platform">{t.ios}</p>
<p class="store-badge__label">{t.iosCta}</p> <p class="store-badge__label">{t.iosCta}</p>
</div> </div>
</a>
</div> </div>
</div>
</div> </div>
<div class="download-cta__phone"> <div class="download-cta__phone">
@@ -63,3 +77,5 @@ const phoneArt = "/assets/cta-phone-art.png";
</div> </div>
</div> </div>
</section> </section>
<script type="application/json" id="site-links-i18n" set:html={siteLinksJson} />

View File

@@ -1,4 +1,5 @@
--- ---
import { assetPath } from '../assets'
import type { Translations } from '../i18n/translations' import type { Translations } from '../i18n/translations'
export interface Props { export interface Props {
@@ -6,16 +7,26 @@ export interface Props {
} }
const { t } = Astro.props const { t } = Astro.props
const images = ["/assets/exp-card-1.png", "/assets/exp-card-2.png", "/assets/exp-card-3.png"] const images = [
assetPath("/assets/exp-card-1.png"),
assetPath("/assets/exp-card-2.png"),
assetPath("/assets/exp-card-3.png"),
]
const imageClasses = ['experience-card__image--one', 'experience-card__image--two', 'experience-card__image--three'] const imageClasses = ['experience-card__image--one', 'experience-card__image--two', 'experience-card__image--three']
--- ---
<section id="experience" class="experience"> <section id="experience" class="experience">
<div class="experience__inner"> <div class="experience__inner">
<div class="experience__heading">
<p class="experience__title"> <p class="experience__title">
{t.title} {t.title}
</p> </p>
<p class="experience__caption">
{t.caption}
</p>
</div>
<div class="experience__body"> <div class="experience__body">
<div class="experience__grid"> <div class="experience__grid">
{t.cards.map((card, index) => ( {t.cards.map((card, index) => (
@@ -36,10 +47,6 @@ const imageClasses = ['experience-card__image--one', 'experience-card__image--tw
</div> </div>
))} ))}
</div> </div>
<p class="experience__caption">
{t.caption}
</p>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -1,4 +1,5 @@
--- ---
import { assetPath } from '../assets'
import type { Translations } from '../i18n/translations' import type { Translations } from '../i18n/translations'
export interface Props { export interface Props {
@@ -6,7 +7,7 @@ export interface Props {
} }
const { t } = Astro.props const { t } = Astro.props
const logoFull = "/assets/footer-logo.png"; const logoFull = assetPath("/assets/footer-logo.png");
--- ---
<footer class="site-footer"> <footer class="site-footer">

View File

@@ -1,4 +1,5 @@
--- ---
import { assetPath } from '../assets'
import { getLocalePath, languageLabels, languageNames, languages, type Lang, type Translations } from '../i18n/translations' import { getLocalePath, languageLabels, languageNames, languages, type Lang, type Translations } from '../i18n/translations'
export interface Props { export interface Props {
@@ -7,9 +8,9 @@ export interface Props {
} }
const { lang, t } = Astro.props const { lang, t } = Astro.props
const logoIcon = "/assets/header-logo-icon.png"; const logoIcon = assetPath("/assets/header-logo-icon.png");
const logoWordmark = "/assets/header-logo-wordmark.svg"; const logoWordmark = assetPath("/assets/header-logo-wordmark.svg");
const globeIcon = "/assets/header-globe.svg"; const globeIcon = assetPath("/assets/header-globe.svg");
const navItems = [ const navItems = [
{ href: '#hero', label: t.nav.home }, { href: '#hero', label: t.nav.home },
{ href: '#features', label: t.nav.features }, { href: '#features', label: t.nav.features },

View File

@@ -1,4 +1,5 @@
--- ---
import { assetPath } from '../assets'
import type { Translations } from '../i18n/translations' import type { Translations } from '../i18n/translations'
export interface Props { export interface Props {
@@ -7,10 +8,11 @@ export interface Props {
} }
const { t, download } = Astro.props const { t, download } = Astro.props
const heroBg = "/assets/hero-bg.png"; const heroBg = assetPath("/assets/hero-bg.png");
const phoneMockup = "/assets/hero-phone.png"; const phoneMockup = assetPath("/assets/hero-phone.png");
const androidIcon = "/assets/cta-android-icon.svg"; const androidIcon = assetPath("/assets/cta-android-icon.svg");
const appleIcon = "/assets/cta-apple-icon.svg"; const appleIcon = assetPath("/assets/cta-apple-icon.svg");
const defaultApkHref = "https://talkspro.xyz/download";
--- ---
<section id="hero" class="hero"> <section id="hero" class="hero">
@@ -39,7 +41,12 @@ const appleIcon = "/assets/cta-apple-icon.svg";
{t.description} {t.description}
</p> </p>
<div class="hero__actions"> <div class="hero__actions">
<a href="#download" class="store-badge store-badge--android hero__store-badge"> <a
href={defaultApkHref}
class="store-badge store-badge--android store-badge--browser hero__store-badge"
rel="noopener noreferrer"
target="_blank"
>
<div class="store-badge__icon-frame"> <div class="store-badge__icon-frame">
<img alt={download.androidAlt} class="store-badge__android-icon" src={androidIcon} /> <img alt={download.androidAlt} class="store-badge__android-icon" src={androidIcon} />
</div> </div>
@@ -48,7 +55,11 @@ const appleIcon = "/assets/cta-apple-icon.svg";
<p class="store-badge__label">{download.androidCta}</p> <p class="store-badge__label">{download.androidCta}</p>
</div> </div>
</a> </a>
<a href="#download" class="store-badge store-badge--ios hero__store-badge"> <a
href="#"
class="store-badge store-badge--ios store-badge--apple hero__store-badge"
data-app-soon="1"
>
<div class="store-badge__icon-frame"> <div class="store-badge__icon-frame">
<img alt={download.appleAlt} class="store-badge__apple-icon" src={appleIcon} /> <img alt={download.appleAlt} class="store-badge__apple-icon" src={appleIcon} />
</div> </div>

View File

@@ -1,4 +1,5 @@
--- ---
import { assetPath } from '../assets'
import type { Translations } from '../i18n/translations' import type { Translations } from '../i18n/translations'
export interface Props { export interface Props {
@@ -6,6 +7,9 @@ export interface Props {
} }
const { t } = Astro.props const { t } = Astro.props
const trustIconSprite = assetPath("/assets/trust-icon-sprite.png")
const trustIconImprovement = assetPath("/assets/trust-icon-improvement.png")
const trustDivider = assetPath("/assets/trust-divider.svg")
const iconClasses = [ const iconClasses = [
'trust-card__icon--one', 'trust-card__icon--one',
'trust-card__icon--two', 'trust-card__icon--two',
@@ -35,7 +39,7 @@ const iconClasses = [
<img <img
alt="" alt=""
class={`trust-card__icon ${iconClasses[index]}`} class={`trust-card__icon ${iconClasses[index]}`}
src={index === 3 ? "/assets/trust-icon-improvement.png" : "/assets/trust-icon-sprite.png"} src={index === 3 ? trustIconImprovement : trustIconSprite}
/> />
</div> </div>
</div> </div>
@@ -47,7 +51,7 @@ const iconClasses = [
{index < t.cards.length - 1 && ( {index < t.cards.length - 1 && (
<div class="trust__divider"> <div class="trust__divider">
<div class="trust__divider-frame"> <div class="trust__divider-frame">
<img alt="" class="trust__divider-image" src="/assets/trust-divider.svg" /> <img alt="" class="trust__divider-image" src={trustDivider} />
</div> </div>
</div> </div>
)} )}

View File

@@ -1,4 +1,5 @@
--- ---
import { assetPath } from '../assets'
import type { Translations } from '../i18n/translations' import type { Translations } from '../i18n/translations'
export interface Props { export interface Props {
@@ -6,14 +7,15 @@ export interface Props {
} }
const { t } = Astro.props const { t } = Astro.props
const underline = "/assets/why-underline.svg"; const underline = assetPath("/assets/why-underline.svg");
const icons = [ const icons = [
"/assets/why-icon-simple.svg", assetPath("/assets/why-icon-simple.svg"),
"/assets/why-icon-familiar.svg", assetPath("/assets/why-icon-familiar.svg"),
"/assets/why-icon-connected.svg", assetPath("/assets/why-icon-connected.svg"),
"/assets/why-icon-modern.svg", assetPath("/assets/why-icon-modern.svg"),
] ]
const iconClasses = ['why-card__icon--square', 'why-card__icon--familiar', 'why-card__icon--square', 'why-card__icon--modern'] const illustrationVideo = assetPath("/assets/why-illustration.mp4")
const iconClasses = ['why-card__icon--simple', 'why-card__icon--familiar', 'why-card__icon--connected', 'why-card__icon--modern']
--- ---
<section class="why"> <section class="why">
@@ -49,7 +51,7 @@ const iconClasses = ['why-card__icon--square', 'why-card__icon--familiar', 'why-
playsinline playsinline
preload="metadata" preload="metadata"
> >
<source src="/assets/why-illustration.mp4" type="video/mp4" /> <source src={illustrationVideo} type="video/mp4" />
</video> </video>
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@@ -27,13 +27,16 @@ const {
</head> </head>
<body class="bg-surface font-sans overflow-x-hidden"> <body class="bg-surface font-sans overflow-x-hidden">
<slot /> <slot />
<script src="/site-links-client.js?v=2" defer></script>
<script> <script>
(() => { (() => {
const header = document.getElementById('site-header'); const header = document.getElementById('site-header');
const getOffset = () => header ? header.offsetHeight : 0; const getOffset = () => header ? header.offsetHeight : 0;
let activeScrollAnimation = 0; let activeScrollAnimation = 0;
const easeInCubic = (t: number) => t * t * t; const easeInOutCubic = (t: number) => (
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
);
const animateScrollTo = (targetTop: number) => { const animateScrollTo = (targetTop: number) => {
const startTop = window.scrollY; const startTop = window.scrollY;
@@ -54,7 +57,7 @@ const {
const step = (now: number) => { const step = (now: number) => {
const elapsed = now - startTime; const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1); const progress = Math.min(elapsed / duration, 1);
const easedProgress = easeInCubic(progress); const easedProgress = easeInOutCubic(progress);
window.scrollTo(0, startTop + distance * easedProgress); window.scrollTo(0, startTop + distance * easedProgress);
@@ -77,6 +80,7 @@ const {
e.preventDefault(); e.preventDefault();
const top = target.getBoundingClientRect().top + window.scrollY - getOffset(); const top = target.getBoundingClientRect().top + window.scrollY - getOffset();
animateScrollTo(top); animateScrollTo(top);
history.pushState(null, '', href);
}); });
}); });

View File

@@ -31,6 +31,6 @@ const t = getTranslations(lang)
<UseCases t={t.useCases} /> <UseCases t={t.useCases} />
<Trust t={t.trust} /> <Trust t={t.trust} />
<!-- AppPreview section disabled per lead request. --> <!-- AppPreview section disabled per lead request. -->
<DownloadCTA t={t.download} /> <DownloadCTA t={t.download} siteLinks={t.siteLinks} />
<Footer t={t.footer} /> <Footer t={t.footer} />
</Base> </Base>

View File

@@ -24,6 +24,6 @@ const t = getTranslations(lang)
<UseCases t={t.useCases} /> <UseCases t={t.useCases} />
<Trust t={t.trust} /> <Trust t={t.trust} />
<!-- AppPreview section disabled per lead request. --> <!-- AppPreview section disabled per lead request. -->
<DownloadCTA t={t.download} /> <DownloadCTA t={t.download} siteLinks={t.siteLinks} />
<Footer t={t.footer} /> <Footer t={t.footer} />
</Base> </Base>

View File

@@ -28,7 +28,7 @@
align-items: center; align-items: center;
width: 100%; width: 100%;
max-width: 1280px; max-width: 1280px;
gap: 32px; gap: 0px;
margin: 0 auto; margin: 0 auto;
padding: 60px 16px; padding: 60px 16px;
} }
@@ -60,7 +60,7 @@
flex-wrap: nowrap; flex-wrap: nowrap;
align-items: center; align-items: center;
flex-shrink: 0; flex-shrink: 0;
gap: 20px; gap: 16px;
max-width: 100%; max-width: 100%;
} }
@@ -129,14 +129,29 @@
border-radius: 20px; border-radius: 20px;
} }
a.store-badge {
box-sizing: border-box;
color: inherit;
text-decoration: none;
cursor: pointer;
}
.download-cta__links-meta {
font-size: 13px;
color: #7a726d;
margin: 14px auto 0;
max-width: min(1180px, calc(100% - 40px));
text-align: center;
}
.store-badge--android { .store-badge--android {
background: #f28a4b; background: #f28a4b;
border: 1px solid #c5834e; border: 1px solid #c5834e;
} }
.store-badge--ios { .store-badge--ios {
background: #383838; background: #121212F0;
border: 1px solid #141414; border: 1px solid #2C2C2C;
} }
.store-badge__icon { .store-badge__icon {
@@ -153,7 +168,7 @@
flex-shrink: 0; flex-shrink: 0;
width: 44px; width: 44px;
height: 44px; height: 44px;
background: #151515; background: #323232;
border-radius: 12px; border-radius: 12px;
} }
@@ -185,10 +200,10 @@
.store-badge__platform { .store-badge__platform {
margin: 0; margin: 0;
font-size: 11px; font-size: 13px;
font-weight: 600; font-weight: 600;
line-height: normal; line-height: normal;
letter-spacing: 0.05px; letter-spacing: var(--ls-13);
} }
.store-badge--android .store-badge__platform { .store-badge--android .store-badge__platform {
@@ -196,7 +211,7 @@
} }
.store-badge--ios .store-badge__platform { .store-badge--ios .store-badge__platform {
color: #ccc; color: #949494;
} }
.store-badge__label { .store-badge__label {
@@ -213,9 +228,17 @@
display: block; display: block;
order: 1; order: 1;
flex-shrink: 0; flex-shrink: 0;
width: 240px;
height: 292px;
}
@media (min-width: 578px) {
.download-cta__phone {
width: min(418px, calc(100vw - 32px)); width: min(418px, calc(100vw - 32px));
height: auto;
aspect-ratio: 418 / 510; aspect-ratio: 418 / 510;
} }
}
.download-cta__phone-crop { .download-cta__phone-crop {
position: absolute; position: absolute;
@@ -271,14 +294,14 @@
} }
} }
@media (min-width: 1023px) { @media (min-width: 1024px) {
.download-cta { .download-cta {
height: 600px; height: 600px;
} }
.download-cta__inner { .download-cta__inner {
flex-direction: row; flex-direction: row;
gap: 0; gap: 16px;
padding: 0 24px; padding: 0 24px;
} }

View File

@@ -49,10 +49,10 @@
.features__description { .features__description {
width: 100%; width: 100%;
margin: 0; margin: 0;
font-size: 18px; font-size: 15px;
font-weight: 400; font-weight: 400;
line-height: 1.5; line-height: 1.5;
letter-spacing: var(--ls-18); letter-spacing: var(--ls-15);
color: #7a726d; color: #7a726d;
text-align: center; text-align: center;
} }
@@ -72,8 +72,8 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 16px; gap: 24px;
padding: 32px; padding: 36px;
background: rgba(255, 255, 255, 0.78); background: rgba(255, 255, 255, 0.78);
border-radius: 30px; border-radius: 30px;
} }
@@ -140,8 +140,8 @@
@media (min-width: 576px) { @media (min-width: 576px) {
.features__header, .features__header,
.features__grid { .features__grid {
padding-left: 28px; padding-left: 24px;
padding-right: 28px; padding-right: 24px;
} }
.features__title { .features__title {
@@ -172,7 +172,7 @@
} }
} }
@media (min-width: 1023px) { @media (min-width: 1024px) {
.features__header { .features__header {
padding: 0 clamp(36px, 14vw, 180px); padding: 0 clamp(36px, 14vw, 180px);
} }
@@ -188,6 +188,11 @@
font-size: 48px; font-size: 48px;
letter-spacing: var(--ls-48); letter-spacing: var(--ls-48);
} }
.features__description {
font-size: 18px;
letter-spacing: var(--ls-18);
}
} }
@media (min-width: 1280px) { @media (min-width: 1280px) {

View File

@@ -103,7 +103,7 @@
} }
} }
@media (min-width: 1023px) { @media (min-width: 1024px) {
.site-footer { .site-footer {
padding: 60px 0; padding: 60px 0;
} }

View File

@@ -73,3 +73,7 @@ body {
--ls-71: -1.58px; --ls-71: -1.58px;
--ls-72: -1.61px; --ls-72: -1.61px;
} }
:target {
scroll-margin-top: 72px;
}

View File

@@ -145,7 +145,8 @@
right: 0; right: 0;
z-index: 60; z-index: 60;
width: 240px; width: 240px;
overflow: hidden; max-height: min(520px, calc(100vh - 96px));
overflow-y: auto;
background: #fff; background: #fff;
border: 1px solid #e3d9d1; border: 1px solid #e3d9d1;
border-radius: 18px; border-radius: 18px;
@@ -158,11 +159,11 @@
.language-switcher__option { .language-switcher__option {
display: block; display: block;
padding: 18px 24px; padding: 14px 24px;
font-size: 18px; font-size: 14px;
font-weight: 600; font-weight: 600;
line-height: normal; line-height: normal;
letter-spacing: var(--ls-18); letter-spacing: var(--ls-14);
color: #7a726d; color: #7a726d;
text-decoration: none; text-decoration: none;
transition: transition:
@@ -232,6 +233,8 @@
} }
.mobile-nav { .mobile-nav {
max-height: calc(100vh - 72px);
overflow-y: auto;
background: #fff; background: #fff;
border-top: 1px solid #e3d9d1; border-top: 1px solid #e3d9d1;
} }
@@ -257,6 +260,11 @@
color: #2e2a28; color: #2e2a28;
text-decoration: none; text-decoration: none;
border-bottom: 1px solid #e3d9d1; border-bottom: 1px solid #e3d9d1;
transition: color 160ms ease;
}
.mobile-nav__link.is-active {
color: #f28a4b;
} }
.mobile-nav__link--last { .mobile-nav__link--last {
@@ -264,18 +272,22 @@
} }
.mobile-nav__languages { .mobile-nav__languages {
display: flex; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: center; align-items: center;
gap: 16px; gap: 12px 16px;
padding: 16px 0; padding: 16px 0;
border-top: 1px solid #e3d9d1; border-top: 1px solid #e3d9d1;
} }
.mobile-nav__language-link { .mobile-nav__language-link {
font-size: 15px; min-width: 0;
font-size: 14px;
font-weight: 600; font-weight: 600;
letter-spacing: var(--ls-15); line-height: 1.25;
letter-spacing: var(--ls-14);
color: #2e2a28; color: #2e2a28;
text-align: center;
text-decoration: none; text-decoration: none;
} }
@@ -285,14 +297,13 @@
@media (max-width: 500px) { @media (max-width: 500px) {
.mobile-nav__languages { .mobile-nav__languages {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
align-items: center; }
gap: 12px 16px;
} }
.mobile-nav__language-link { @media (min-width: 501px) and (max-width: 1024px) {
text-align: center; .mobile-nav__languages {
grid-template-columns: repeat(5, minmax(0, 1fr));
} }
} }
@@ -322,7 +333,7 @@
background: #e07a3b; background: #e07a3b;
} }
@media (min-width: 1023px) { @media (min-width: 1024px) {
.site-header__bar { .site-header__bar {
padding: 0 24px; padding: 0 24px;
} }

View File

@@ -191,7 +191,7 @@
} }
} }
@media (max-width: 1023px) { @media (max-width: 1024px) {
.hero__actions { .hero__actions {
flex-wrap: nowrap; flex-wrap: nowrap;
} }
@@ -246,6 +246,28 @@
font-size: 56px; font-size: 56px;
letter-spacing: var(--ls-56); letter-spacing: var(--ls-56);
} }
.hero__phone-column {
padding-top: 60px;
}
.hero__phone-frame {
width: min(580px, 100%);
height: auto;
aspect-ratio: 116 / 191;
}
.hero__phone-crop {
position: relative;
inset: auto;
overflow: visible;
}
.hero__phone {
position: static;
width: 100%;
height: auto;
}
} }
@media (min-width: 1024px) { @media (min-width: 1024px) {
@@ -259,23 +281,47 @@
} }
.hero__phone-column { .hero__phone-column {
align-items: center; position: relative;
justify-content: flex-start;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
height: 100%; height: 100%;
padding-top: 60px; overflow: hidden;
} }
.hero__phone-frame { .hero__phone-frame {
width: 100%; position: absolute;
height: auto; top: 60px;
aspect-ratio: 673 / 1108; left: 0;
right: 0;
bottom: 0;
overflow: hidden;
}
.hero__phone-crop {
position: relative;
inset: auto;
height: 100%;
overflow: visible;
}
.hero__phone {
position: static;
width: auto;
height: 100%;
}
.hero__actions {
flex-wrap: nowrap;
}
.hero__store-badge {
flex: 0 1 auto;
min-width: 0;
} }
.hero__content { .hero__content {
width: 660px; width: clamp(560px, 44vw, 600px);
padding: 80px 0; padding: 0;
} }
} }
@@ -295,6 +341,37 @@
.hero__inner { .hero__inner {
padding: 0; padding: 0;
} }
.hero__phone-column {
align-items: flex-start;
padding-top: 60px;
}
.hero__phone-frame {
position: static;
top: auto;
right: auto;
bottom: auto;
overflow: visible;
flex: 1 0 0;
width: auto;
max-width: calc(955px * 116 / 191);
height: 955px;
aspect-ratio: 116 / 191;
}
.hero__phone-crop {
height: auto;
}
.hero__phone {
width: 100%;
height: 100%;
}
.hero__content {
width: clamp(560px, 52vw, 660px);
}
} }
@media (min-width: 1376px) { @media (min-width: 1376px) {

View File

@@ -33,9 +33,10 @@
.app-preview__description { .app-preview__description {
width: 100%; width: 100%;
margin: 0; margin: 0;
font-size: 18px; font-size: 15px;
font-weight: 400; font-weight: 400;
line-height: 1.5; line-height: 1.5;
letter-spacing: var(--ls-15);
color: #7a726d; color: #7a726d;
text-align: center; text-align: center;
} }
@@ -205,7 +206,7 @@
} }
} }
@media (min-width: 1023px) { @media (min-width: 1024px) {
.app-preview { .app-preview {
padding: 120px clamp(32px, 6vw, 130px) 0; padding: 120px clamp(32px, 6vw, 130px) 0;
} }
@@ -214,6 +215,11 @@
font-size: 48px; font-size: 48px;
} }
.app-preview__description {
font-size: 18px;
letter-spacing: var(--ls-18);
}
.app-preview__side-phone, .app-preview__side-phone,
.app-preview__control-wrap { .app-preview__control-wrap {
display: flex; display: flex;

View File

@@ -18,6 +18,14 @@
margin: 0 auto; margin: 0 auto;
} }
.experience__heading {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
gap: 24px;
}
.experience__title { .experience__title {
width: 100%; width: 100%;
margin: 0; margin: 0;
@@ -49,7 +57,7 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
width: 100%; width: 100%;
gap: 16px; gap: 36px;
padding: 0 0 36px; padding: 0 0 36px;
overflow: hidden; overflow: hidden;
background: linear-gradient(to bottom, #fef0eb, #fff); background: linear-gradient(to bottom, #fef0eb, #fff);
@@ -84,7 +92,7 @@
} }
.experience-card__image--one { .experience-card__image--one {
top: -86.50%; top: -86.5%;
left: 0; left: 0;
width: 100%; width: 100%;
height: 298.5%; height: 298.5%;
@@ -139,10 +147,10 @@
.experience__caption { .experience__caption {
width: 100%; width: 100%;
margin: 0; margin: 0;
font-size: 18px; font-size: 15px;
font-weight: 400; font-weight: 400;
line-height: 1.5; line-height: 1.5;
letter-spacing: var(--ls-18); letter-spacing: var(--ls-15);
color: #7a726d; color: #7a726d;
text-align: center; text-align: center;
} }
@@ -228,10 +236,10 @@
.use-cases__description { .use-cases__description {
width: 100%; width: 100%;
margin: 0; margin: 0;
font-size: 18px; font-size: 15px;
font-weight: 400; font-weight: 400;
line-height: 1.5; line-height: 1.5;
letter-spacing: var(--ls-18); letter-spacing: var(--ls-15);
color: #7a726d; color: #7a726d;
text-align: left; text-align: left;
} }
@@ -308,7 +316,7 @@
color: #7a726d; color: #7a726d;
} }
@media (max-width: 639px) { @media (max-width: 578px) {
.use-cases__rows { .use-cases__rows {
gap: 24px; gap: 24px;
overflow: visible; overflow: visible;
@@ -349,8 +357,8 @@
@media (min-width: 576px) { @media (min-width: 576px) {
.experience, .experience,
.use-cases { .use-cases {
padding-left: 28px; padding-left: 24px;
padding-right: 28px; padding-right: 24px;
} }
.experience__title, .experience__title,
@@ -360,7 +368,7 @@
} }
} }
@media (min-width: 626px) { @media (min-width: 578px) {
.experience__grid { .experience__grid {
grid-template-columns: repeat(2, minmax(0, 320px)); grid-template-columns: repeat(2, minmax(0, 320px));
} }
@@ -376,7 +384,7 @@
} }
} }
@media (min-width: 640px) { @media (min-width: 578px) {
.use-case-row { .use-case-row {
grid-template-columns: minmax(220px, 300px) minmax(280px, 1fr); grid-template-columns: minmax(220px, 300px) minmax(280px, 1fr);
height: 120px; height: 120px;
@@ -407,7 +415,7 @@
} }
} }
@media (min-width: 920px) { @media (min-width: 1200px) {
.experience__grid { .experience__grid {
grid-template-columns: repeat(3, minmax(0, 320px)); grid-template-columns: repeat(3, minmax(0, 320px));
} }
@@ -417,17 +425,19 @@
} }
} }
@media (min-width: 1023px) { @media (min-width: 576px) {
.experience-card__title {
white-space: nowrap;
}
}
@media (min-width: 1024px) {
.experience { .experience {
padding-top: 60px; padding-top: 60px;
padding-bottom: 60px; padding-bottom: 60px;
padding-left: 36px; padding-left: 36px;
padding-right: 36px; padding-right: 36px;
} }
.experience-card__title {
white-space: nowrap;
}
} }
@media (min-width: 1200px) { @media (min-width: 1200px) {
@@ -436,9 +446,15 @@
font-size: 48px; font-size: 48px;
letter-spacing: var(--ls-48); letter-spacing: var(--ls-48);
} }
.experience__caption,
.use-cases__description {
font-size: 18px;
letter-spacing: var(--ls-18);
}
} }
@media (min-width: 1295px) { @media (min-width: 1201px) {
.use-cases { .use-cases {
padding: 60px 64px; padding: 60px 64px;
} }

View File

@@ -40,10 +40,10 @@
.trust__description { .trust__description {
width: 100%; width: 100%;
margin: 0; margin: 0;
font-size: 18px; font-size: 15px;
font-weight: 400; font-weight: 400;
line-height: 1.5; line-height: 1.5;
letter-spacing: var(--ls-18); letter-spacing: var(--ls-15);
color: #7a726d; color: #7a726d;
} }
@@ -162,7 +162,7 @@
height: 100%; height: 100%;
} }
@media (max-width: 1022px) { @media (max-width: 1023px) {
.trust-card__copy { .trust-card__copy {
align-items: center; align-items: center;
text-align: center; text-align: center;
@@ -188,8 +188,8 @@
@media (min-width: 576px) { @media (min-width: 576px) {
.trust { .trust {
padding-left: 28px; padding-left: 24px;
padding-right: 28px; padding-right: 24px;
} }
.trust__title { .trust__title {
@@ -215,7 +215,7 @@
} }
} }
@media (min-width: 768px) and (max-width: 1022px) { @media (min-width: 768px) and (max-width: 1023px) {
.trust__grid > .trust-card:nth-child(1), .trust__grid > .trust-card:nth-child(1),
.trust__grid > .trust-card:nth-child(5) { .trust__grid > .trust-card:nth-child(5) {
position: relative; position: relative;
@@ -223,7 +223,7 @@
.trust__grid > .trust-card:nth-child(1)::after, .trust__grid > .trust-card:nth-child(1)::after,
.trust__grid > .trust-card:nth-child(5)::after { .trust__grid > .trust-card:nth-child(5)::after {
content: ''; content: "";
position: absolute; position: absolute;
top: 50%; top: 50%;
right: -16px; right: -16px;
@@ -234,21 +234,46 @@
} }
} }
@media (min-width: 1023px) { @media (min-width: 1024px) {
.trust { .trust {
padding-top: 60px; padding-top: 60px;
padding-bottom: 60px; padding-bottom: 60px;
padding-left: 36px; padding-left: 28px;
padding-right: 36px; padding-right: 28px;
} }
.trust__grid { .trust__grid {
display: flex; display: flex;
gap: 24px; gap: 14px;
} }
.trust-card { .trust-card {
flex: 1; flex: 1;
padding: 16px 8px;
}
.trust-card__icon-frame {
width: 112px;
height: 112px;
}
.trust-card__copy {
align-items: center;
text-align: center;
}
.trust-card__title {
font-size: 15px;
line-height: 20px;
letter-spacing: var(--ls-15);
text-align: center;
}
.trust-card__description {
font-size: 14px;
line-height: 1.45;
letter-spacing: var(--ls-14);
text-align: center;
} }
.trust__divider { .trust__divider {
@@ -261,6 +286,36 @@
font-size: 48px; font-size: 48px;
letter-spacing: var(--ls-48); letter-spacing: var(--ls-48);
} }
.trust__description {
font-size: 18px;
letter-spacing: var(--ls-18);
}
.trust__grid {
gap: 24px;
}
.trust-card {
padding: 24px;
}
.trust-card__icon-frame {
width: 128px;
height: 128px;
}
.trust-card__title {
font-size: 16px;
line-height: 22px;
letter-spacing: var(--ls-16);
}
.trust-card__description {
font-size: 15px;
line-height: 1.5;
letter-spacing: var(--ls-15);
}
} }
@media (min-width: 1376px) { @media (min-width: 1376px) {

View File

@@ -80,13 +80,20 @@
.why__description { .why__description {
width: 100%; width: 100%;
margin: 0; margin: 0;
font-size: 18px; font-size: 15px;
font-weight: 400; font-weight: 400;
line-height: 1.5; line-height: 1.5;
letter-spacing: var(--ls-18); letter-spacing: var(--ls-15);
color: #7a726d; color: #7a726d;
} }
@media (min-width: 1200px) {
.why__description {
font-size: 18px;
letter-spacing: var(--ls-18);
}
}
.why__illustration { .why__illustration {
position: relative; position: relative;
display: block; display: block;
@@ -119,8 +126,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
min-width: 0; min-width: 0;
gap: 24px; gap: 20px;
padding: 36px; padding: 20px 16px;
overflow: clip; overflow: clip;
background: #fef0eb; background: #fef0eb;
border: 1px solid #e8e4de; border: 1px solid #e8e4de;
@@ -132,8 +139,8 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
width: 80px; width: 60px;
height: 80px; height: 60px;
overflow: clip; overflow: clip;
background: #f08458; background: #f08458;
border-radius: 9999px; border-radius: 9999px;
@@ -143,19 +150,24 @@
display: block; display: block;
} }
.why-card__icon--square { .why-card__icon--simple {
width: 44px; width: 33.001px;
height: 44px; height: 32.973px;
} }
.why-card__icon--familiar { .why-card__icon--familiar {
width: 38px; width: 28.5px;
height: 40px; height: 30px;
}
.why-card__icon--connected {
width: 33px;
height: 32.936px;
} }
.why-card__icon--modern { .why-card__icon--modern {
width: 24px; width: 18.125px;
height: 44px; height: 33px;
} }
.why-card__copy { .why-card__copy {
@@ -194,6 +206,35 @@
padding-right: 20px; padding-right: 20px;
} }
.why-card {
gap: 24px;
}
.why-card__icon-frame {
width: 80px;
height: 80px;
}
.why-card__icon--simple {
width: 44.001px;
height: 43.964px;
}
.why-card__icon--familiar {
width: 38px;
height: 40px;
}
.why-card__icon--connected {
width: 44px;
height: 43.914px;
}
.why-card__icon--modern {
width: 24.168px;
height: 44px;
}
.why__title { .why__title {
font-size: 32px; font-size: 32px;
letter-spacing: var(--ls-32); letter-spacing: var(--ls-32);
@@ -202,8 +243,8 @@
@media (min-width: 576px) { @media (min-width: 576px) {
.why { .why {
padding-left: 28px; padding-left: 24px;
padding-right: 28px; padding-right: 24px;
} }
.why__title { .why__title {
@@ -223,11 +264,8 @@
letter-spacing: var(--ls-42); letter-spacing: var(--ls-42);
} }
.why__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.why-card { .why-card {
padding: 36px;
min-height: 152px; min-height: 152px;
} }
@@ -237,7 +275,7 @@
} }
} }
@media (min-width: 1023px) { @media (min-width: 1024px) {
.why { .why {
padding-top: 60px; padding-top: 60px;
padding-bottom: 60px; padding-bottom: 60px;
@@ -245,6 +283,10 @@
padding-right: 36px; padding-right: 36px;
} }
.why__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.why__intro { .why__intro {
flex-direction: row; flex-direction: row;
} }