mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 15:21:08 +00:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aaa2095899 | |||
| 9679a84f32 | |||
| ab76c3a441 | |||
| 1eb3b0834d | |||
| 7db7366299 | |||
| e99e0e148d | |||
| 8b3e2e31dc | |||
| bbe94c6ae5 | |||
| 4c6eeaef26 | |||
| 3c95678eb1 | |||
| 3d911b13d8 | |||
| d98fabd095 | |||
| 664a5399e8 | |||
| ddf5fd2e64 | |||
| 6654745199 | |||
| 7882c9c837 | |||
| 743ac0877d | |||
| cd0ddce90e |
@@ -34,7 +34,10 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
android:
|
||||
if: github.repository == 'liketrek/TREK'
|
||||
# Gated on a repository variable so the release can ship iOS alone while the
|
||||
# Google Play account and its signing secrets are not yet in place. Set the
|
||||
# repository variable NATIVE_RELEASE_ANDROID to 1 to turn the Android half on.
|
||||
if: github.repository == 'liketrek/TREK' && vars.NATIVE_RELEASE_ANDROID == '1'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 40
|
||||
environment: store
|
||||
@@ -148,44 +151,31 @@ jobs:
|
||||
ASC_KEY_P8_B64: ${{ secrets.ASC_KEY_P8_B64 }}
|
||||
run: printf '%s' "$ASC_KEY_P8_B64" | base64 -d > "$RUNNER_TEMP/AuthKey.p8"
|
||||
|
||||
- name: Archive
|
||||
working-directory: client/ios/App
|
||||
- name: Distribution certificate
|
||||
env:
|
||||
APPLE_DIST_CERT_P12_B64: ${{ secrets.APPLE_DIST_CERT_P12_B64 }}
|
||||
run: printf '%s' "$APPLE_DIST_CERT_P12_B64" | base64 -d > "$RUNNER_TEMP/dist.p12"
|
||||
|
||||
# Signs for distribution and uploads to TestFlight. Automatic signing cannot
|
||||
# run on a device-less CI runner (it signs for development and then needs a
|
||||
# development profile, which needs a registered device), so client/fastlane/Fastfile
|
||||
# imports the stored distribution certificate and creates the App Store profile
|
||||
# through the API key.
|
||||
- name: Build and upload to TestFlight
|
||||
working-directory: client
|
||||
env:
|
||||
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
ASC_KEY_PATH: ${{ runner.temp }}/AuthKey.p8
|
||||
CERT_P12_PATH: ${{ runner.temp }}/dist.p12
|
||||
CERT_P12_PASSWORD: ${{ secrets.APPLE_DIST_CERT_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
run: >-
|
||||
xcodebuild archive
|
||||
-project App.xcodeproj
|
||||
-scheme App
|
||||
-configuration Release
|
||||
-destination 'generic/platform=iOS'
|
||||
-archivePath "$RUNNER_TEMP/TREK.xcarchive"
|
||||
MARKETING_VERSION=${{ steps.version.outputs.version }}
|
||||
CURRENT_PROJECT_VERSION=${{ steps.version.outputs.build }}
|
||||
DEVELOPMENT_TEAM="$APPLE_TEAM_ID"
|
||||
-allowProvisioningUpdates
|
||||
-authenticationKeyPath "$RUNNER_TEMP/AuthKey.p8"
|
||||
-authenticationKeyID "$ASC_KEY_ID"
|
||||
-authenticationKeyIssuerID "$ASC_ISSUER_ID"
|
||||
MARKETING_VERSION: ${{ steps.version.outputs.version }}
|
||||
CURRENT_PROJECT_VERSION: ${{ steps.version.outputs.build }}
|
||||
FASTLANE_SKIP_UPDATE_CHECK: '1'
|
||||
FASTLANE_HIDE_CHANGELOG: '1'
|
||||
run: fastlane ios beta
|
||||
|
||||
# ExportOptions.plist says destination=upload, so this step sends the build
|
||||
# to App Store Connect, where it shows up in TestFlight after processing.
|
||||
- name: Export and upload to App Store Connect
|
||||
working-directory: client/ios/App
|
||||
env:
|
||||
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||||
ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }}
|
||||
run: >-
|
||||
xcodebuild -exportArchive
|
||||
-archivePath "$RUNNER_TEMP/TREK.xcarchive"
|
||||
-exportOptionsPlist ../ExportOptions.plist
|
||||
-exportPath "$RUNNER_TEMP/export"
|
||||
-allowProvisioningUpdates
|
||||
-authenticationKeyPath "$RUNNER_TEMP/AuthKey.p8"
|
||||
-authenticationKeyID "$ASC_KEY_ID"
|
||||
-authenticationKeyIssuerID "$ASC_ISSUER_ID"
|
||||
|
||||
- name: Remove the API key
|
||||
- name: Remove secrets
|
||||
if: always()
|
||||
run: rm -f "$RUNNER_TEMP/AuthKey.p8"
|
||||
run: rm -f "$RUNNER_TEMP/AuthKey.p8" "$RUNNER_TEMP/dist.p12"
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# iOS release signing and TestFlight upload, driven by .github/workflows/native-release.yml.
|
||||
#
|
||||
# Automatic signing cannot work on a CI runner with no registered devices: it signs
|
||||
# the archive for development and then needs a development provisioning profile, which
|
||||
# requires a device. So this signs manually for distribution instead. The distribution
|
||||
# certificate and the App Store provisioning profile are created (or reused) through the
|
||||
# App Store Connect API key, which means no certificate or profile has to be stored as a
|
||||
# secret: the key the workflow already holds is enough.
|
||||
#
|
||||
# The lane expects these in the environment (the workflow sets them):
|
||||
# ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_PATH — the App Store Connect API key
|
||||
# APPLE_TEAM_ID — the developer team
|
||||
# MARKETING_VERSION, CURRENT_PROJECT_VERSION — the version and build number
|
||||
# RUNNER_TEMP — a writable scratch directory
|
||||
|
||||
default_platform(:ios)
|
||||
|
||||
APP_IDENTIFIER = "com.liketrek.trek"
|
||||
PROJECT = "ios/App/App.xcodeproj"
|
||||
|
||||
platform :ios do
|
||||
desc "Build the signed Release app and upload it to TestFlight (internal testing)."
|
||||
lane :beta do
|
||||
key = app_store_connect_api_key(
|
||||
key_id: ENV.fetch("ASC_KEY_ID"),
|
||||
issuer_id: ENV.fetch("ASC_ISSUER_ID"),
|
||||
key_filepath: ENV.fetch("ASC_KEY_PATH"),
|
||||
in_house: false,
|
||||
)
|
||||
|
||||
# A throwaway keychain with a known password, so fastlane can unlock the imported
|
||||
# private key for code signing (without the password it cannot set the key's access
|
||||
# list and the certificate is never usable).
|
||||
keychain_name = "trek_ci.keychain"
|
||||
keychain_password = "trek-ci-#{ENV.fetch('GITHUB_RUN_ID', 'local')}"
|
||||
create_keychain(
|
||||
name: keychain_name,
|
||||
password: keychain_password,
|
||||
default_keychain: true,
|
||||
unlock: true,
|
||||
timeout: 3600,
|
||||
lock_when_sleeps: false,
|
||||
)
|
||||
keychain_path = File.expand_path("~/Library/Keychains/#{keychain_name}-db")
|
||||
|
||||
# Import the one distribution certificate from its stored .p12, rather than
|
||||
# minting a new one each run — Apple caps distribution certificates at two, so
|
||||
# a fresh one per build would quickly hit the limit. The matching App Store
|
||||
# profile is created (or reused) through the API key.
|
||||
import_certificate(
|
||||
certificate_path: ENV.fetch("CERT_P12_PATH"),
|
||||
certificate_password: ENV.fetch("CERT_P12_PASSWORD"),
|
||||
keychain_name: keychain_name,
|
||||
keychain_path: keychain_path,
|
||||
keychain_password: keychain_password,
|
||||
)
|
||||
get_provisioning_profile(
|
||||
api_key: key,
|
||||
app_identifier: APP_IDENTIFIER,
|
||||
development: false,
|
||||
)
|
||||
|
||||
# Pin the project to that profile for this build; automatic signing stays the default
|
||||
# a developer sees when they open the project in Xcode.
|
||||
update_code_signing_settings(
|
||||
path: PROJECT,
|
||||
use_automatic_signing: false,
|
||||
team_id: ENV.fetch("APPLE_TEAM_ID"),
|
||||
code_sign_identity: "Apple Distribution",
|
||||
bundle_identifier: APP_IDENTIFIER,
|
||||
profile_name: lane_context[SharedValues::SIGH_NAME],
|
||||
targets: ["App"],
|
||||
)
|
||||
|
||||
build_app(
|
||||
project: PROJECT,
|
||||
scheme: "App",
|
||||
configuration: "Release",
|
||||
export_method: "app-store",
|
||||
output_directory: File.join(ENV.fetch("RUNNER_TEMP"), "export"),
|
||||
xcargs: [
|
||||
"MARKETING_VERSION=#{ENV.fetch('MARKETING_VERSION')}",
|
||||
"CURRENT_PROJECT_VERSION=#{ENV.fetch('CURRENT_PROJECT_VERSION')}",
|
||||
].join(" "),
|
||||
export_options: {
|
||||
signingStyle: "manual",
|
||||
provisioningProfiles: { APP_IDENTIFIER => lane_context[SharedValues::SIGH_NAME] },
|
||||
},
|
||||
)
|
||||
|
||||
# Upload to App Store Connect; it shows up in TestFlight after Apple finishes processing.
|
||||
# Internal testing needs no App Review, so nothing here submits the app for review.
|
||||
upload_to_testflight(
|
||||
api_key: key,
|
||||
skip_waiting_for_build_processing: true,
|
||||
skip_submission: true,
|
||||
)
|
||||
end
|
||||
end
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
+62
-23
@@ -3,6 +3,8 @@ import { Haptics, ImpactStyle, NotificationType } from '@capacitor/haptics';
|
||||
import { SplashScreen } from '@capacitor/splash-screen';
|
||||
import { StatusBar, Style } from '@capacitor/status-bar';
|
||||
import logoUrl from '../public/icons/icon-white.svg';
|
||||
import logoBlack from '../public/logo-dark.svg'; // #000 wordmark, for light backgrounds
|
||||
import logoWhite from '../public/logo-light.svg'; // #fff wordmark, for dark backgrounds
|
||||
import { getIapClient, productIdFor, type Purchase } from '../src/native/iap';
|
||||
import { CLOUD_URL, PRIVACY_URL, SERVER_DOCS_URL } from '../src/native/links';
|
||||
import { probeDemo, probeServer, type ProbeFailure, type ProbeGet } from '../src/native/serverAddress';
|
||||
@@ -147,11 +149,17 @@ function topbar(onBack: () => void): HTMLElement {
|
||||
|
||||
// ── Welcome ─────────────────────────────────────────────────────────────────
|
||||
function showWelcome(opts: { back?: boolean } = {}): void {
|
||||
const hero = el('img', { src: logoUrl, alt: '', className: 'hero-mark' });
|
||||
// The full TREK wordmark, in the variant that reads on the current scheme.
|
||||
const hero = el(
|
||||
'span',
|
||||
{ className: 'hero-logo' },
|
||||
el('img', { src: logoWhite, alt: 'TREK', className: 'on-dark' }),
|
||||
el('img', { src: logoBlack, alt: '', className: 'on-light' })
|
||||
);
|
||||
|
||||
const connect = el(
|
||||
'button',
|
||||
{ type: 'button', className: 'btn' },
|
||||
{ type: 'button', className: 'btn btn-ghost' },
|
||||
el(
|
||||
'span',
|
||||
{ className: 'btn-row' },
|
||||
@@ -167,7 +175,7 @@ function showWelcome(opts: { back?: boolean } = {}): void {
|
||||
|
||||
const cloud = el(
|
||||
'button',
|
||||
{ type: 'button', className: 'btn btn-ghost' },
|
||||
{ type: 'button', className: 'btn' },
|
||||
el(
|
||||
'span',
|
||||
{ className: 'btn-row' },
|
||||
@@ -181,7 +189,7 @@ function showWelcome(opts: { back?: boolean } = {}): void {
|
||||
showCloud();
|
||||
});
|
||||
|
||||
const demo = el('button', { type: 'button', className: 'linkbtn' }, t('native.connect.tryDemo'));
|
||||
const demo = el('button', { type: 'button', className: 'demo-link' }, t('native.connect.tryDemo'));
|
||||
demo.addEventListener('click', () => runDemo(demo));
|
||||
|
||||
show(
|
||||
@@ -189,10 +197,10 @@ function showWelcome(opts: { back?: boolean } = {}): void {
|
||||
'div',
|
||||
{},
|
||||
hero,
|
||||
el('h1', { className: 'title' }, t('native.welcome.headline')),
|
||||
el('h1', { className: 'title brand-title' }, t('native.welcome.headline')),
|
||||
el('p', { className: 'sub' }, t('native.welcome.sub')),
|
||||
connect,
|
||||
cloud,
|
||||
connect,
|
||||
demo,
|
||||
footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))
|
||||
),
|
||||
@@ -248,9 +256,6 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
|
||||
fail(result.reason);
|
||||
});
|
||||
|
||||
const demo = el('button', { type: 'button', className: 'linkbtn' }, t('native.connect.tryDemo'));
|
||||
demo.addEventListener('click', () => runDemo(demo, error));
|
||||
|
||||
show(
|
||||
el(
|
||||
'div',
|
||||
@@ -259,7 +264,6 @@ function showConnect(prefill = '', opts: { back?: boolean } = {}): void {
|
||||
el('h1', { className: 'title-sm' }, t('native.connect.title')),
|
||||
el('p', { className: 'sub' }, t('native.connect.hint')),
|
||||
form,
|
||||
demo,
|
||||
footLinks(externalLink(t('native.connect.whatIsServer'), SERVER_DOCS_URL))
|
||||
),
|
||||
opts
|
||||
@@ -295,7 +299,13 @@ function showCloud(opts: { back?: boolean } = {}): void {
|
||||
'div',
|
||||
{},
|
||||
topbar(() => showWelcome({ back: true })),
|
||||
el('h1', { className: 'title-sm' }, t('native.cloud.title')),
|
||||
el(
|
||||
'div',
|
||||
{ className: 'cloud-wordmark', role: 'img', ariaLabel: t('native.cloud.title') },
|
||||
el('img', { src: logoWhite, alt: '', className: 'on-dark' }),
|
||||
el('img', { src: logoBlack, alt: '', className: 'on-light' }),
|
||||
el('span', { className: 'cloud-tag' }, 'cloud')
|
||||
),
|
||||
el('p', { className: 'sub' }, t('native.cloud.sub')),
|
||||
el(
|
||||
'div',
|
||||
@@ -326,15 +336,27 @@ function money(cents: number): string {
|
||||
return new Intl.NumberFormat(locale, { style: 'currency', currency: 'EUR' }).format(cents / 100);
|
||||
}
|
||||
|
||||
// A sample catalogue for the dev preview only: the cloud site is behind a gate
|
||||
// during development, so its /api/plans is unreachable from a local build. Never
|
||||
// used in a real build (see the import.meta.env.DEV guard below).
|
||||
const SAMPLE_PLANS: CloudPlan[] = [
|
||||
{ id: 'start', name: 'Start', monthlyCents: 599, yearlyCents: 5990, aiDocuments: 0, storageGb: 10 },
|
||||
{ id: 'plus', name: 'Plus', monthlyCents: 999, yearlyCents: 9990, aiDocuments: 100, storageGb: 50 },
|
||||
{ id: 'pro', name: 'Pro', monthlyCents: 1599, yearlyCents: 15990, aiDocuments: 500, storageGb: 200 },
|
||||
{ id: 'studio', name: 'Studio', monthlyCents: 2399, yearlyCents: 23990, aiDocuments: 2000, storageGb: 1000 },
|
||||
];
|
||||
|
||||
async function fetchPlans(): Promise<CloudPlan[] | null> {
|
||||
try {
|
||||
const res = await get(`${CLOUD_URL}/api/plans`);
|
||||
const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data;
|
||||
const plans = (data as { plans?: CloudPlan[] })?.plans;
|
||||
return Array.isArray(plans) && plans.length ? plans : null;
|
||||
if (Array.isArray(plans) && plans.length) return plans;
|
||||
} catch {
|
||||
return null;
|
||||
// Fall through to the preview fallback.
|
||||
}
|
||||
if (import.meta.env?.DEV) return SAMPLE_PLANS;
|
||||
return null;
|
||||
}
|
||||
|
||||
function subscribe(plan: CloudPlan, interval: 'month' | 'year'): void {
|
||||
@@ -352,6 +374,9 @@ function planCard(plan: CloudPlan, interval: 'month' | 'year', featured: boolean
|
||||
const price = interval === 'year' ? plan.yearlyCents : plan.monthlyCents;
|
||||
const per = interval === 'year' ? t('native.plans.perYear') : t('native.plans.perMonth');
|
||||
const feat = (text: string) => el('span', {}, icon(I.check, 16), text);
|
||||
// An absent feature (e.g. a plan with no AI documents): a muted dash, not a
|
||||
// green tick, so "none" never reads as a perk.
|
||||
const featOff = (text: string) => el('span', { className: 'off' }, icon('M5 12h14', 16), text);
|
||||
const sub = el('button', { type: 'button', className: 'btn' }, t('native.plans.subscribe'));
|
||||
sub.addEventListener('click', () => subscribe(plan, interval));
|
||||
return el(
|
||||
@@ -368,7 +393,9 @@ function planCard(plan: CloudPlan, interval: 'month' | 'year', featured: boolean
|
||||
{ className: 'plan-feats' },
|
||||
feat(t('native.plans.unlimited')),
|
||||
feat(t('native.plans.storage', { n: String(plan.storageGb) })),
|
||||
feat(plan.aiDocuments > 0 ? t('native.plans.aiDocs', { n: String(plan.aiDocuments) }) : t('native.plans.noAi'))
|
||||
plan.aiDocuments > 0
|
||||
? feat(t('native.plans.aiDocs', { n: String(plan.aiDocuments) }))
|
||||
: featOff(t('native.plans.noAi'))
|
||||
),
|
||||
sub
|
||||
);
|
||||
@@ -381,12 +408,7 @@ function showPlans(opts: { back?: boolean } = {}): void {
|
||||
const content = el('div', { className: 'loading' }, el('span', { className: 'spinner' }));
|
||||
|
||||
const monthBtn = el('button', { type: 'button' }, t('native.plans.monthly'));
|
||||
const yearBtn = el(
|
||||
'button',
|
||||
{ type: 'button' },
|
||||
t('native.plans.yearly'),
|
||||
el('span', { className: 'save' }, t('native.plans.save'))
|
||||
);
|
||||
const yearBtn = el('button', { type: 'button' }, t('native.plans.yearly'));
|
||||
const seg = el('div', { className: 'segmented' }, monthBtn, yearBtn);
|
||||
|
||||
const paintSeg = () => {
|
||||
@@ -397,7 +419,7 @@ function showPlans(opts: { back?: boolean } = {}): void {
|
||||
const list = plans;
|
||||
if (!list) return;
|
||||
content.className = 'plans';
|
||||
content.replaceChildren(...list.map((p, i) => planCard(p, interval, i === list.length - 1)));
|
||||
content.replaceChildren(...list.map((p) => planCard(p, interval, false)));
|
||||
};
|
||||
const choose = (next: 'month' | 'year') => {
|
||||
if (interval === next) return;
|
||||
@@ -494,6 +516,7 @@ function showClaim(plan: CloudPlan, interval: 'month' | 'year', opts: { back?: b
|
||||
autocapitalize: 'off',
|
||||
spellcheck: false,
|
||||
value: '',
|
||||
placeholder: t('native.claim.addressLabel'),
|
||||
});
|
||||
slugInput.setAttribute('inputmode', 'url');
|
||||
slugInput.setAttribute('autocomplete', 'off');
|
||||
@@ -507,7 +530,13 @@ function showClaim(plan: CloudPlan, interval: 'month' | 'year', opts: { back?: b
|
||||
);
|
||||
const slugNote = el('p', { className: 'hint' });
|
||||
|
||||
const emailInput = el('input', { type: 'email', value: '', autocapitalize: 'off', spellcheck: false });
|
||||
const emailInput = el('input', {
|
||||
type: 'email',
|
||||
value: '',
|
||||
autocapitalize: 'off',
|
||||
spellcheck: false,
|
||||
placeholder: t('native.claim.emailLabel'),
|
||||
});
|
||||
emailInput.setAttribute('inputmode', 'email');
|
||||
emailInput.setAttribute('autocomplete', 'email');
|
||||
emailInput.setAttribute('enterkeyhint', 'go');
|
||||
@@ -782,7 +811,17 @@ async function start(): Promise<void> {
|
||||
iap.isAvailable().then((ok) => {
|
||||
iapAvailable = ok;
|
||||
});
|
||||
const { url } = await TrekShell.getServer();
|
||||
let url: string | null = null;
|
||||
try {
|
||||
({ url } = await TrekShell.getServer());
|
||||
} catch {
|
||||
// No native shell under us (a web preview, or a simulator without the
|
||||
// plugin): there is no remembered server, so start at the welcome screen
|
||||
// rather than leaving a blank page.
|
||||
showWelcome();
|
||||
await SplashScreen.hide().catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (!url) {
|
||||
showWelcome();
|
||||
} else {
|
||||
|
||||
+281
-28
@@ -4,6 +4,49 @@
|
||||
* background and never flashes. Everything past this screen is the UI the
|
||||
* chosen server delivers.
|
||||
*/
|
||||
|
||||
/*
|
||||
* TREK's own type, self-hosted (latin subset, bundled — the shell loads offline
|
||||
* and hotlinks nothing). Poppins is the display face across the app and
|
||||
* liketrek.com; MuseoModerno draws the "cloud" of the TREK Cloud wordmark.
|
||||
*/
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('./fonts/poppins-latin-500.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('./fonts/poppins-latin-600.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Poppins';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('./fonts/poppins-latin-700.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'MuseoModerno';
|
||||
font-style: normal;
|
||||
font-weight: 600 700;
|
||||
font-display: swap;
|
||||
src: url('./fonts/museomoderno-latin-600.woff2') format('woff2');
|
||||
}
|
||||
/* Italic, subset to the letters of "cloud" — the wordmark's badge, nothing else. */
|
||||
@font-face {
|
||||
font-family: 'MuseoModerno';
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('./fonts/museomoderno-italic-600.woff2') format('woff2');
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #09090b;
|
||||
--bg-2: #101013;
|
||||
@@ -18,6 +61,11 @@
|
||||
--accent-ink: #09090b;
|
||||
--danger: #f87171;
|
||||
--ok: #4ade80;
|
||||
/* The hairline the ambient grid is drawn in, and a travel-dusk wash for depth. */
|
||||
--grid: rgba(255, 255, 255, 0.055);
|
||||
--wash-1: rgba(129, 140, 248, 0.12);
|
||||
--wash-2: rgba(244, 114, 182, 0.08);
|
||||
--font: 'Poppins', -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto, system-ui, sans-serif;
|
||||
--radius: 16px;
|
||||
--ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
@@ -39,6 +87,9 @@
|
||||
--accent-ink: #ffffff;
|
||||
--danger: #dc2626;
|
||||
--ok: #16a34a;
|
||||
--grid: rgba(0, 0, 0, 0.045);
|
||||
--wash-1: rgba(79, 70, 229, 0.07);
|
||||
--wash-2: rgba(219, 39, 119, 0.05);
|
||||
color-scheme: light;
|
||||
}
|
||||
.shell-logo,
|
||||
@@ -56,9 +107,11 @@ html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background: var(--bg);
|
||||
background:
|
||||
radial-gradient(90% 55% at 50% -12%, var(--wash-1), transparent 62%),
|
||||
radial-gradient(80% 50% at 50% 112%, var(--wash-2), transparent 64%), var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', Roboto, system-ui, sans-serif;
|
||||
font-family: var(--font);
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
@@ -66,17 +119,55 @@ body {
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* An ambient glow behind the content, so the flat dark gains a little depth. */
|
||||
/*
|
||||
* The backdrop, in two fixed layers behind the content. First a soft dusk wash
|
||||
* at top and bottom — a trace of the colour the app's photos carry, never loud
|
||||
* enough to read as decoration. Then a quiet grid that fades out through a
|
||||
* radial mask: depth, the way liketrek.com's hero carries it.
|
||||
*/
|
||||
/* The grid, strongest at the top-right and fading left and down — the half the
|
||||
photo leaves open, so the two meet in a soft seam down the middle. */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(60% 50% at 50% -5%, color-mix(in srgb, var(--ink) 7%, transparent), transparent 70%),
|
||||
radial-gradient(50% 40% at 50% 110%, color-mix(in srgb, var(--ink) 4%, transparent), transparent 70%);
|
||||
inset: 0 0 auto;
|
||||
height: 440px;
|
||||
background-image:
|
||||
linear-gradient(var(--grid) 1px, transparent 1px), linear-gradient(90deg, var(--grid) 1px, transparent 1px);
|
||||
background-size: 54px 54px;
|
||||
-webkit-mask-image: radial-gradient(135% 95% at 100% 0%, #000 34%, transparent 80%);
|
||||
mask-image: radial-gradient(135% 95% at 100% 0%, #000 34%, transparent 80%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
/*
|
||||
* A travel photo as a top band (Kyoto at dusk, the same image liketrek.com uses
|
||||
* in its pricing section), under a dark gradient and faded out at the bottom so
|
||||
* the headline stays legible. Dark-first; in light mode it steps back to the
|
||||
* quiet grid instead, which reads better on a white page.
|
||||
*/
|
||||
/* The travel photo (Kyoto at dusk, as on liketrek.com), anchored left and faded
|
||||
out toward the right so it hands over to the grid, and down into the page.
|
||||
Dark-first; a dark photo over a white page reads badly, so light mode drops it
|
||||
and keeps the grid alone. */
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0 0 auto;
|
||||
height: 440px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(9, 9, 11, 0.4) 0%, rgba(9, 9, 11, 0.72) 62%, var(--bg) 100%),
|
||||
url('./img/kyoto.webp') left 28% / cover no-repeat;
|
||||
-webkit-mask-image: linear-gradient(90deg, #000 0%, #000 40%, transparent 80%);
|
||||
mask-image: linear-gradient(90deg, #000 0%, #000 40%, transparent 80%);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
body::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.shell {
|
||||
position: relative;
|
||||
@@ -213,11 +304,47 @@ body::before {
|
||||
|
||||
/* The big hero mark on the welcome screen, with a gentle breathing float. */
|
||||
.hero-mark {
|
||||
width: 76px;
|
||||
height: 76px;
|
||||
margin-bottom: 18px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 20px;
|
||||
animation: floaty 6s ease-in-out infinite;
|
||||
filter: drop-shadow(0 10px 30px color-mix(in srgb, var(--ink) 18%, transparent));
|
||||
filter: drop-shadow(0 14px 36px color-mix(in srgb, var(--wash-1) 90%, transparent))
|
||||
drop-shadow(0 6px 16px color-mix(in srgb, var(--ink) 22%, transparent));
|
||||
}
|
||||
|
||||
/* The full TREK wordmark on the welcome screen, in the variant that reads on the scheme. */
|
||||
.hero-logo {
|
||||
display: block;
|
||||
margin-bottom: 22px;
|
||||
filter: drop-shadow(0 14px 36px color-mix(in srgb, var(--wash-1) 90%, transparent))
|
||||
drop-shadow(0 6px 16px color-mix(in srgb, var(--ink) 22%, transparent));
|
||||
animation: logoRise 0.7s var(--ease) both;
|
||||
}
|
||||
.hero-logo img {
|
||||
display: block;
|
||||
height: 62px;
|
||||
width: auto;
|
||||
}
|
||||
.hero-logo .on-light {
|
||||
display: none;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
.hero-logo .on-dark {
|
||||
display: none;
|
||||
}
|
||||
.hero-logo .on-light {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
@keyframes logoRise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@keyframes floaty {
|
||||
0%,
|
||||
@@ -231,24 +358,69 @@ body::before {
|
||||
|
||||
/* ── Type ────────────────────────────────────────────────────────────────── */
|
||||
.title {
|
||||
font-size: 1.9rem;
|
||||
line-height: 1.12;
|
||||
font-size: 2.05rem;
|
||||
line-height: 1.1;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: -0.03em;
|
||||
margin: 0;
|
||||
/* Honour the newline in the headline string (e.g. "Deine Reisen.\nDein Plan."). */
|
||||
white-space: pre-line;
|
||||
}
|
||||
/* The welcome headline as the brand says it: MuseoModerno, lower case. */
|
||||
.brand-title {
|
||||
font-family: 'MuseoModerno', 'Poppins', sans-serif;
|
||||
font-weight: 700;
|
||||
text-transform: lowercase;
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
/* The TREK Cloud lockup (cloud intro screen): the wordmark plus the "cloud"
|
||||
badge in italic MuseoModerno, an inverted pill, exactly as on liketrek.com. */
|
||||
.cloud-wordmark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
.cloud-wordmark img {
|
||||
display: block;
|
||||
height: 38px;
|
||||
width: auto;
|
||||
}
|
||||
.cloud-wordmark .on-light {
|
||||
display: none;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
.cloud-wordmark .on-dark {
|
||||
display: none;
|
||||
}
|
||||
.cloud-wordmark .on-light {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
.cloud-tag {
|
||||
font-family: 'MuseoModerno', system-ui, sans-serif;
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.35;
|
||||
padding: 2px 11px;
|
||||
border-radius: 999px;
|
||||
color: var(--bg);
|
||||
background: var(--ink);
|
||||
}
|
||||
.title-sm {
|
||||
font-size: 1.45rem;
|
||||
line-height: 1.2;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.18;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.015em;
|
||||
letter-spacing: -0.025em;
|
||||
margin: 0;
|
||||
}
|
||||
.sub {
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
line-height: 1.55;
|
||||
font-size: 1.02rem;
|
||||
font-size: 0.9rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -263,10 +435,14 @@ body::before {
|
||||
background: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
0 8px 22px -14px rgba(0, 0, 0, 0.55),
|
||||
0 1px 2px rgba(0, 0, 0, 0.25);
|
||||
transition:
|
||||
transform 0.14s var(--ease),
|
||||
opacity 0.2s,
|
||||
background 0.2s,
|
||||
box-shadow 0.2s,
|
||||
border-color 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -284,6 +460,9 @@ body::before {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
border-color: var(--edge);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.22),
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--ink) 4%, transparent);
|
||||
}
|
||||
.btn-ghost:active {
|
||||
background: var(--surface-2);
|
||||
@@ -318,6 +497,34 @@ body::before {
|
||||
align-self: center;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
/* "Try the demo": a tertiary action, but a tidy pill so it reads as a choice, not stray text. */
|
||||
.demo-link {
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
align-self: stretch;
|
||||
width: 100%;
|
||||
margin-top: 2px;
|
||||
padding: 9px 18px;
|
||||
min-height: 40px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--edge);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition:
|
||||
color 0.2s,
|
||||
background 0.2s,
|
||||
border-color 0.2s,
|
||||
transform 0.14s var(--ease);
|
||||
}
|
||||
.demo-link:active {
|
||||
transform: scale(0.97);
|
||||
background: var(--surface-2);
|
||||
color: var(--ink);
|
||||
}
|
||||
.linkbtn:active {
|
||||
color: var(--ink);
|
||||
}
|
||||
@@ -329,14 +536,21 @@ a.linkbtn {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 2px 18px;
|
||||
gap: 4px 22px;
|
||||
margin-top: 6px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--edge);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
.foot-links a {
|
||||
color: var(--faint);
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
padding: 8px 2px;
|
||||
padding: 6px 2px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.foot-links a:active {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* ── Address form ────────────────────────────────────────────────────────── */
|
||||
@@ -351,13 +565,16 @@ a.linkbtn {
|
||||
.field input {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
font-size: 1.05rem;
|
||||
padding: 16px 18px;
|
||||
font-size: 1rem;
|
||||
padding: 13px 16px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--edge);
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
outline: none;
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.18),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--ink) 4%, transparent);
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.25s;
|
||||
@@ -574,10 +791,19 @@ a.linkbtn {
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
background: var(--surface);
|
||||
box-shadow:
|
||||
0 2px 10px -4px rgba(0, 0, 0, 0.3),
|
||||
inset 0 0 0 1px color-mix(in srgb, var(--ink) 3%, transparent);
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s,
|
||||
transform 0.14s var(--ease);
|
||||
}
|
||||
.plan.featured {
|
||||
box-shadow:
|
||||
0 10px 30px -10px color-mix(in srgb, var(--wash-1) 85%, transparent),
|
||||
0 2px 8px -3px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.plan:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
@@ -604,7 +830,7 @@ a.linkbtn {
|
||||
font-weight: 500;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
margin-left: 3px;
|
||||
margin-left: 7px;
|
||||
}
|
||||
.plan-feats {
|
||||
margin: 10px 0 14px;
|
||||
@@ -623,6 +849,10 @@ a.linkbtn {
|
||||
flex: none;
|
||||
color: var(--ok);
|
||||
}
|
||||
.plan-feats .off,
|
||||
.plan-feats .off svg {
|
||||
color: var(--faint);
|
||||
}
|
||||
.plan .btn {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
@@ -654,21 +884,44 @@ a.linkbtn {
|
||||
}
|
||||
|
||||
/* ── Claim: address + email, and the provisioning wait ───────────────────── */
|
||||
/* One rounded field holding the address input and its fixed ".liketrek.com"
|
||||
suffix — the input itself is seamless, the box and its states live here. */
|
||||
.slug-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: 14px;
|
||||
padding-right: 16px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--edge);
|
||||
background: var(--surface);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(0, 0, 0, 0.18),
|
||||
inset 0 1px 0 color-mix(in srgb, var(--ink) 4%, transparent);
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.25s;
|
||||
}
|
||||
.slug-field .slug-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: right;
|
||||
text-align: left;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.slug-field:focus-within {
|
||||
border-color: var(--edge-strong);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--ink) 9%, transparent);
|
||||
}
|
||||
.slug-field.bad {
|
||||
border-color: var(--danger);
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--danger) 16%, transparent);
|
||||
animation: shake 0.3s;
|
||||
}
|
||||
.slug-suffix {
|
||||
flex: none;
|
||||
color: var(--muted);
|
||||
font-size: 0.98rem;
|
||||
padding-left: 2px;
|
||||
font-size: 0.95rem;
|
||||
padding-left: 4px;
|
||||
user-select: none;
|
||||
}
|
||||
.hint {
|
||||
|
||||
@@ -6,7 +6,7 @@ const native: TranslationStrings = {
|
||||
'native.connect.placeholder': 'https://trek.example.com',
|
||||
'native.connect.submit': 'Verbinden',
|
||||
'native.connect.checking': 'Wird geprüft…',
|
||||
'native.connect.tryDemo': 'Noch keinen Server? Probier die Demo aus',
|
||||
'native.connect.tryDemo': 'Demo ausprobieren',
|
||||
'native.connect.whatIsServer': 'Was ist ein TREK-Server?',
|
||||
'native.connect.errorInvalid': 'Das sieht nicht nach einer Webadresse aus.',
|
||||
'native.connect.errorUnreachable':
|
||||
@@ -25,9 +25,8 @@ const native: TranslationStrings = {
|
||||
'native.privacy': 'Datenschutzerklärung',
|
||||
'native.settings.server': 'Server', // same-as-en
|
||||
'native.settings.serverHint': 'Diese App ist mit {server} verbunden.',
|
||||
'native.welcome.headline': 'Reisen gemeinsam planen.',
|
||||
'native.welcome.sub':
|
||||
'TREK hält jeden Tag, Ort, jede Buchung und Kosten in einem gemeinsamen Plan, den eure ganze Gruppe live bearbeitet.',
|
||||
'native.welcome.headline': 'Deine Reisen.\nDein Plan.',
|
||||
'native.welcome.sub': 'Gemeinsam in Echtzeit planen: Touren, Budgets, Journal, Fotobuch — mit KI.',
|
||||
'native.welcome.connect': 'Mit deinem TREK verbinden',
|
||||
'native.welcome.connectSub': 'Die Adresse öffnen, die du schon nutzt',
|
||||
'native.welcome.getCloud': 'TREK Cloud holen',
|
||||
@@ -39,7 +38,7 @@ const native: TranslationStrings = {
|
||||
'native.cloud.f2': 'In Minuten eingerichtet, Updates übernehmen wir',
|
||||
'native.cloud.f3': 'Deine Daten bleiben deine — keine Werbung, kein Tracking',
|
||||
'native.cloud.cta': 'Loslegen',
|
||||
'native.cloud.haveServer': 'Ich habe schon ein TREK',
|
||||
'native.cloud.haveServer': 'Ich habe schon einen TREK-Server',
|
||||
'native.connecting': 'Verbinde mit {host}',
|
||||
'native.back': 'Zurück',
|
||||
'native.plans.title': 'Tarif wählen',
|
||||
@@ -52,12 +51,12 @@ const native: TranslationStrings = {
|
||||
'native.plans.aiDocs': '{n} KI-Dokumente im Monat',
|
||||
'native.plans.noAi': 'Keine KI-Dokumente',
|
||||
'native.plans.storage': '{n} GB Speicher',
|
||||
'native.plans.unlimited': 'Unbegrenzt Orte, Reisen und Personen',
|
||||
'native.plans.unlimited': 'Unbegrenzt Orte, Reisen & Personen',
|
||||
'native.plans.subscribe': 'Abonnieren',
|
||||
'native.plans.loadError': 'Die Tarife konnten nicht geladen werden. Prüfe deine Verbindung und versuch es erneut.',
|
||||
'native.plans.retry': 'Erneut versuchen',
|
||||
'native.plans.soon': 'In-App-Abos kommen bald. Vorerst auf der Website abschließen.',
|
||||
'native.claim.title': 'Deine TREK einrichten',
|
||||
'native.claim.title': 'Fast geschafft',
|
||||
'native.claim.sub': 'Wähle deine Adresse. Wir richten sie sofort ein.',
|
||||
'native.claim.addressLabel': 'Deine Adresse',
|
||||
'native.claim.taken': 'Diese Adresse ist vergeben.',
|
||||
|
||||
@@ -6,7 +6,7 @@ const native: TranslationStrings = {
|
||||
'native.connect.placeholder': 'https://trek.example.com',
|
||||
'native.connect.submit': 'Connect',
|
||||
'native.connect.checking': 'Checking…',
|
||||
'native.connect.tryDemo': 'No server yet? Try the demo',
|
||||
'native.connect.tryDemo': 'Try the demo',
|
||||
'native.connect.whatIsServer': 'What is a TREK server?',
|
||||
'native.connect.errorInvalid': 'That does not look like a web address.',
|
||||
'native.connect.errorUnreachable': 'No server answered at this address. Check the address and your connection.',
|
||||
@@ -24,9 +24,8 @@ const native: TranslationStrings = {
|
||||
'native.privacy': 'Privacy policy',
|
||||
'native.settings.server': 'Server',
|
||||
'native.settings.serverHint': 'This app is connected to {server}.',
|
||||
'native.welcome.headline': 'Plan trips together.',
|
||||
'native.welcome.sub':
|
||||
'TREK keeps every day, place, booking and cost in one live plan your whole group edits together.',
|
||||
'native.welcome.headline': 'Your Trip.\nYour Plan.',
|
||||
'native.welcome.sub': 'Plan together in real time: tours, budgets, journals, photo book — with AI.',
|
||||
'native.welcome.connect': 'Connect your TREK',
|
||||
'native.welcome.connectSub': 'Open the address you already use',
|
||||
'native.welcome.getCloud': 'Get TREK Cloud',
|
||||
@@ -51,12 +50,12 @@ const native: TranslationStrings = {
|
||||
'native.plans.aiDocs': '{n} AI documents a month',
|
||||
'native.plans.noAi': 'No AI documents',
|
||||
'native.plans.storage': '{n} GB storage',
|
||||
'native.plans.unlimited': 'Unlimited places, trips and people',
|
||||
'native.plans.unlimited': 'Unlimited places, trips & people',
|
||||
'native.plans.subscribe': 'Subscribe',
|
||||
'native.plans.loadError': 'Could not load the plans. Check your connection and try again.',
|
||||
'native.plans.retry': 'Try again',
|
||||
'native.plans.soon': 'In-app subscriptions are coming. For now, finish on the website.',
|
||||
'native.claim.title': 'Create your TREK',
|
||||
'native.claim.title': 'Almost there',
|
||||
'native.claim.sub': 'Choose your address. We set it up right away.',
|
||||
'native.claim.addressLabel': 'Your address',
|
||||
'native.claim.taken': 'That address is taken.',
|
||||
|
||||
Reference in New Issue
Block a user