diff --git a/app/live/layout.tsx b/app/live/layout.tsx new file mode 100644 index 0000000..0322e3c --- /dev/null +++ b/app/live/layout.tsx @@ -0,0 +1,42 @@ +import type { Metadata } from "next"; +import { Anybody, Lexend, JetBrains_Mono } from "next/font/google"; +import type { ReactNode } from "react"; + +import "./live.css"; + +const anybody = Anybody({ + subsets: ["latin"], + display: "swap", + variable: "--live-font-anybody", +}); + +const lexend = Lexend({ + subsets: ["latin"], + display: "swap", + variable: "--live-font-lexend", +}); + +const jetbrainsMono = JetBrains_Mono({ + subsets: ["latin"], + weight: ["500", "600"], + display: "swap", + variable: "--live-font-mono", +}); + +export const metadata: Metadata = { + title: "Get Kokio & Usage Guide", + description: + "Download Kokio for iOS and Android, and see exactly how it works: sign in with a passkey, buy an eSIM, install it, and manage everything from a wallet only you control.", + alternates: { + canonical: "/live", + types: { "text/markdown": "/live.md" }, + }, +}; + +export default function LiveLayout({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/app/live/live.css b/app/live/live.css new file mode 100644 index 0000000..5f20b97 --- /dev/null +++ b/app/live/live.css @@ -0,0 +1,1106 @@ +.kokio-live, +.kokio-live * { + box-sizing: border-box; +} + +.kokio-live { + --chrome-bg: #131519; + --chrome-panel: #1a1d22; + --chrome-ink: #edf1f2; + --chrome-muted: #9aa6ac; + --chrome-faint: #7a858c; + --chrome-border: #2b3036; + --chrome-accent: #64d2ff; + --chrome-accent-fg: #06232b; + --chrome-code-bg: #21252b; + --chrome-danger: #e2846e; + --chrome-danger-soft: #2f201b; + --chrome-danger-border: #4a2e26; + + --app-bg: #000000; + --app-card: #ffd60a; + --app-card-fg: #000000; + --app-muted-fg: #aeaeb2; + --app-inactive: #777777; + --app-text: #ffffff; + --app-primary: #ff9f0a; + --app-primary-fg: #000000; + --app-secondary: #ffd60a; + --app-secondary-fg: #000000; + --app-muted: #46464b; + --app-surface: #1a1a1a; + --app-input-bg: #7676803d; + --app-success: #22c55e; + --app-info: #64d2ff; + --app-warning: #f59e0b; + --app-destructive: #ef4444; + --app-pay-btn: #ffd60a; + --app-golden: #ffaf01; + --app-link: #4a9eff; + --app-handle: rgba(60, 60, 67, 0.25); + --app-handle-arrow: rgba(60, 60, 67, 0.85); + --app-lpa-bg: #ffffff; + --app-highlight: #ffcc00; + --app-foreground2: #aeaeb2; + --app-hero-btn-bg: #ffffff; + --app-hero-btn-fg: #000000; + --app-shop-cta: #ffaf01; + --app-icon: #9ba1a6; + --app-item-bg: #1c1c1e; + --app-gradient-dark: #404040; + --app-wallet-balance-fg: #ffffff; + --app-modal-bg: rgba(60, 60, 60, 0.9); + + color-scheme: dark; + margin: 0; + background: var(--chrome-bg); + color: var(--chrome-ink); + font-family: + var(--live-font-lexend), + system-ui, + -apple-system, + sans-serif; + font-size: 16px; + line-height: 1.55; + -webkit-font-smoothing: antialiased; +} + +.kokio-live[data-theme="light"] { + color-scheme: light; + --chrome-bg: #eef1f3; + --chrome-panel: #ffffff; + --chrome-ink: #1c2b30; + --chrome-muted: #5b6770; + --chrome-faint: #626d76; /* darkened from the artifact's #8b969c — 2.67:1 failed WCAG AA badly on --chrome-bg, this is 4.66:1 (ticket #10) */ + --chrome-border: #d7dee1; + --chrome-accent: #1f6b78; /* darkened from the artifact's #2a8fa0 — 3.34:1 failed WCAG AA where used as text (callout strong), this is 5.39:1 (ticket #10) */ + --chrome-accent-fg: #ffffff; + --chrome-code-bg: #e4e9eb; + --chrome-danger: #c0392b; + --chrome-danger-soft: #fbe9e7; + --chrome-danger-border: #eec2ba; + + --app-bg: #a8d8e8; + --app-card: #ffffff; + --app-card-fg: #1a3d4f; + --app-muted-fg: #7abccc; + --app-inactive: #7abccc; + --app-text: #1a3d4f; + --app-primary: #2a8fa0; + --app-primary-fg: #ffffff; + --app-secondary: #e8614a; + --app-secondary-fg: #ffffff; + --app-muted: #c5e8f2; + --app-surface: #eef8fc; + --app-input-bg: #eef8fc; + --app-success: #22c55e; + --app-info: #64d2ff; + --app-warning: #f59e0b; + --app-destructive: #ef4444; + --app-pay-btn: #e8614a; + --app-golden: #e8614a; + --app-link: #2a8fa0; + --app-handle: rgba(60, 60, 67, 0.25); + --app-handle-arrow: rgba(60, 60, 67, 0.85); + --app-lpa-bg: var(--app-muted); + --app-highlight: #e8614a; + --app-foreground2: #4a8090; + --app-hero-btn-bg: #7abccc; + --app-hero-btn-fg: #ffffff; + --app-shop-cta: #7abccc; + --app-icon: #2a8fa0; + --app-item-bg: #ffffff; + --app-gradient-dark: #ffffff; + --app-wallet-balance-fg: #000000; + --app-modal-bg: rgba(255, 255, 255, 0.95); +} + +.kokio-live a { + color: inherit; +} +.kokio-live :focus-visible { + outline: 2px solid var(--chrome-accent); + outline-offset: 2px; + border-radius: 4px; +} +.kokio-live h1, +.kokio-live h2, +.kokio-live h3 { + font-family: var(--live-font-anybody), sans-serif; + text-wrap: balance; + margin: 0; +} +.kokio-live button { + font-family: inherit; +} +@media (prefers-reduced-motion: reduce) { + .kokio-live * { + animation-duration: 0.001ms !important; + transition-duration: 0.001ms !important; + } +} +.kokio-live [hidden] { + display: none !important; +} +@keyframes kokio-live-spin { + to { + transform: rotate(360deg); + } +} +.kokio-live .a-spinner { + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid transparent; + border-top-color: currentColor; + border-right-color: currentColor; + animation: kokio-live-spin 0.7s linear infinite; + flex: none; +} + +.kokio-live .wrap { + max-width: 1080px; + margin: 0 auto; + padding: 0 24px; +} + +.kokio-live .masthead { + padding: 56px 0 32px; + border-bottom: 1px solid var(--chrome-border); +} +.kokio-live .masthead h1 { + font-size: clamp(30px, 5vw, 44px); + font-weight: 800; + letter-spacing: -0.01em; + margin-bottom: 12px; +} +.kokio-live .masthead p { + max-width: 64ch; + color: var(--chrome-muted); + font-size: 15.5px; + margin: 0 0 4px; +} +.kokio-live .masthead .note { + font-size: 12.5px; + color: var(--chrome-faint); + margin-top: 10px; +} +.kokio-live .masthead .note code { + font-family: var(--live-font-mono), monospace; + background: var(--chrome-code-bg); + padding: 1px 5px; + border-radius: 4px; +} + +.kokio-live .controls-outer { + position: sticky; + top: 0; + z-index: 6; + background: color-mix(in srgb, var(--chrome-bg) 90%, transparent); + backdrop-filter: blur(10px); + border-bottom: 1px solid var(--chrome-border); +} +.kokio-live .controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 14px 26px; + max-width: 1080px; + margin: 0 auto; + padding: 12px 24px; +} +.kokio-live .control-group { + display: flex; + align-items: center; + gap: 10px; +} +.kokio-live .control-label { + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--chrome-faint); + white-space: nowrap; +} +.kokio-live .segmented { + display: inline-flex; + background: var(--chrome-code-bg); + border-radius: 9px; + padding: 3px; + gap: 2px; +} +.kokio-live .segmented button { + appearance: none; + border: none; + background: transparent; + color: var(--chrome-muted); + font-size: 12.5px; + font-weight: 600; + padding: 6px 12px; + border-radius: 7px; + cursor: pointer; + transition: + background 0.15s, + color 0.15s; +} +.kokio-live .segmented button:hover { + color: var(--chrome-ink); +} +.kokio-live .segmented button.active { + background: var(--chrome-accent); + color: var(--chrome-accent-fg); +} +.kokio-live .rail { + display: flex; + gap: 2px; + overflow-x: auto; + flex: 1; + min-width: 220px; +} +.kokio-live .rail a { + flex: 0 0 auto; + font-size: 12.5px; + font-weight: 600; + color: var(--chrome-muted); + padding: 7px 11px; + border-radius: 999px; + white-space: nowrap; + text-decoration: none; +} +.kokio-live .rail a:hover { + color: var(--chrome-ink); + background: var(--chrome-code-bg); +} + +.kokio-live .steps { + padding: 16px 0 8px; +} +.kokio-live .step { + display: grid; + grid-template-columns: 1fr; + gap: 32px; + padding: 40px 0; + border-bottom: 1px solid var(--chrome-border); + scroll-margin-top: 64px; +} +.kokio-live .step:last-child { + border-bottom: none; +} +@media (min-width: 900px) { + .kokio-live .step { + grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.15fr); + align-items: start; + gap: 48px; + padding: 52px 0; + } + .kokio-live .step[data-alt="1"] .step-text { + order: 2; + } + .kokio-live .step[data-alt="1"] .step-visual { + order: 1; + } +} +.kokio-live .step-index { + font-family: var(--live-font-mono), monospace; + font-size: 12.5px; + color: var(--chrome-accent); + font-weight: 600; + letter-spacing: 0.05em; + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 8px; +} +.kokio-live .step-text h2 { + font-size: clamp(21px, 3vw, 26px); + font-weight: 800; + margin-bottom: 8px; +} +.kokio-live .step-summary { + color: var(--chrome-muted); + font-size: 14.5px; + max-width: 48ch; + margin: 0 0 20px; +} +.kokio-live .step-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 13px; + max-width: 50ch; + counter-reset: step-counter; +} +.kokio-live .step-list li { + display: flex; + gap: 12px; + align-items: baseline; +} +.kokio-live .step-list li::before { + counter-increment: step-counter; + content: counter(step-counter); + font-family: var(--live-font-mono), monospace; + font-size: 11.5px; + font-weight: 700; + color: var(--chrome-accent-fg); + background: var(--chrome-accent); + flex: 0 0 auto; + width: 20px; + height: 20px; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + transform: translateY(2px); +} +.kokio-live .step-list li p { + margin: 0; + font-size: 14.5px; +} + +.kokio-live .callout { + margin-top: 20px; + padding: 13px 15px; + border-radius: 12px; + font-size: 13px; + line-height: 1.6; + max-width: 50ch; + background: var(--chrome-code-bg); + border: 1px solid var(--chrome-border); + color: var(--chrome-ink); +} +.kokio-live .callout strong { + color: var(--chrome-accent); +} +.kokio-live .callout--danger { + background: var(--chrome-danger-soft); + border-color: var(--chrome-danger-border); +} +.kokio-live .callout--danger strong { + color: var(--chrome-danger); +} +.kokio-live .soon-pill { + font-family: var(--live-font-mono), monospace; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.03em; + color: var(--chrome-accent); + background: var(--chrome-code-bg); + border: 1px solid var(--chrome-border); + padding: 3px 9px; + border-radius: 999px; +} +.kokio-live .try-hint { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11.5px; + color: var(--chrome-faint); + margin-top: 14px; +} +.kokio-live .try-hint svg { + width: 12px; + height: 12px; + flex: none; +} + +/* ---- closing tips, between the step guide and the Manifesto CTA ---- */ +.kokio-live .closing-tips { + padding: 40px 0 8px; + border-top: 1px solid var(--chrome-border); +} +.kokio-live .closing-tips h2 { + font-size: clamp(22px, 3.5vw, 30px); + font-weight: 800; + letter-spacing: -0.01em; + margin-bottom: 8px; +} +.kokio-live .closing-tips > p { + color: var(--chrome-muted); + font-size: 15px; + max-width: 64ch; + margin: 0 0 20px; +} +.kokio-live .closing-tips ul { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 12px; +} +@media (min-width: 700px) { + .kokio-live .closing-tips ul { + grid-template-columns: 1fr 1fr; + column-gap: 28px; + } +} +.kokio-live .closing-tips li { + display: flex; + gap: 10px; + align-items: flex-start; + font-size: 14.5px; + line-height: 1.55; + color: var(--chrome-ink); +} +.kokio-live .closing-tips li::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--chrome-accent); + flex: none; + margin-top: 8px; +} + +/* ---- device stage ---- */ +.kokio-live .stage { + background: var(--app-bg); + border-radius: 22px; + padding: 22px 16px; + border: 1px solid var(--chrome-border); + overflow-x: auto; + display: flex; + align-items: flex-start; +} +.kokio-live .phone { + width: 100%; + max-width: 330px; + margin: 0 auto; + font-family: var(--live-font-lexend), sans-serif; +} +.kokio-live .src-tag { + font-family: var(--live-font-mono), monospace; + font-size: 10px; + color: var(--chrome-faint); + text-align: center; + margin-bottom: 10px; +} + +.kokio-live footer.page-footer { + padding: 28px 24px 48px; + text-align: center; + color: var(--chrome-faint); + font-size: 12.5px; +} +.kokio-live footer.page-footer .footer-link { + color: var(--chrome-accent); + text-decoration: underline; +} + +/* ================= shared app-styled components ================= */ +.kokio-live .a-card { + background: var(--app-card); + color: var(--app-card-fg); + border-radius: 21px; + padding: 16px; +} +.kokio-live .esim-card { + background: var(--app-card); + color: var(--app-card-fg); + border-radius: 21px; + padding: 16px; + display: flex; + flex-direction: column; + gap: 8px; + border: 1px solid var(--app-muted); +} +.kokio-live .a-statrow { + display: flex; + gap: 14px; + align-items: center; + flex-wrap: wrap; +} +.kokio-live .a-stat { + display: flex; + align-items: center; + gap: 3px; + color: var(--app-card-fg); + font-size: 14px; +} +.kokio-live .a-stat svg { + width: 18px; + height: 18px; + flex: none; +} +.kokio-live .a-stat b { + font-weight: 800; +} +.kokio-live .a-flag { + position: absolute; + top: -10px; + right: 30px; + font-size: 34px; + line-height: 1; + filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.25)); +} +.kokio-live .a-card-wrap { + position: relative; + width: 100%; +} +.kokio-live .a-country { + font-size: 20px; + font-weight: 700; + color: var(--app-card-fg); + padding-right: 90px; +} +.kokio-live .a-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + border: none; + border-radius: 10px; + padding: 10px 0; + font-size: 14px; + font-weight: 600; + cursor: pointer; + width: 100%; + font-family: inherit; +} +.kokio-live .a-btn svg { + width: 15px; + height: 15px; +} +.kokio-live .a-btn-primary { + background: var(--app-primary); + color: var(--app-primary-fg); +} +.kokio-live .a-btn-highlight { + background: var(--app-highlight); + color: #000000; + font-weight: 600; +} +.kokio-live .a-btn-fgborder { + background: transparent; + border: 1px solid var(--app-foreground2); + color: var(--app-foreground2); + font-weight: 400; +} +.kokio-live .a-view-btn { + all: unset; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + gap: 2px; + width: 100%; + padding: 8px; + border-radius: 16px; + margin-top: 8px; + cursor: pointer; + background: var(--app-shop-cta); +} +.kokio-live .a-view-btn b { + color: var(--app-card-fg); + font-size: 14px; +} +.kokio-live .a-view-btn .vb-right { + display: flex; + align-items: center; + gap: 2px; + padding-left: 16px; +} +.kokio-live .a-view-btn .vb-right svg { + width: 20px; + height: 20px; + stroke: var(--app-card-fg); + fill: none; + stroke-width: 1.8; +} +.kokio-live .a-view-btn .vb-right span { + color: var(--app-card-fg); + font-size: 14px; + font-weight: 800; +} +.kokio-live .a-btn-outline { + background: transparent; + border: 1px solid var(--app-muted); + color: var(--app-text); +} +.kokio-live .a-btn[disabled] { + opacity: 0.45; + cursor: not-allowed; +} +.kokio-live .a-pill { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + padding: 3px 8px; + border-radius: 8px; +} +.kokio-live .a-muted { + color: var(--app-inactive); + font-size: 12.5px; +} +.kokio-live .a-sublabel { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--app-inactive); + margin: 14px 0 8px; +} + +.kokio-live .a-order-meta { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 4px 0; +} +.kokio-live .a-chevron { + margin-left: auto; + color: var(--app-muted-fg); + width: 14px; + height: 14px; +} + +.kokio-live .a-detail { + background: var(--app-surface); + border-radius: 12px; + margin: 6px 4px 0; + padding: 14px; + display: flex; + flex-direction: column; +} +.kokio-live .a-summary-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 0; +} +.kokio-live .a-summary-row .lbl { + font-size: 13px; + color: var(--app-inactive); +} +.kokio-live .a-summary-row .val { + font-size: 13px; + font-weight: 600; + color: var(--app-text); + font-variant-numeric: tabular-nums; +} +.kokio-live .a-summary-row .val.mono { + font-family: var(--live-font-mono), monospace; + font-size: 12.5px; + letter-spacing: 0.01em; +} +.kokio-live .a-usage-track { + height: 8px; + border-radius: 4px; + overflow: hidden; + background: var(--app-muted); + margin-top: -2px; + margin-bottom: 6px; +} +.kokio-live .a-usage-fill { + height: 8px; + border-radius: 4px; + transition: + width 0.2s ease, + background 0.2s ease; +} + +.kokio-live .a-copy-row { + display: flex; + align-items: center; + padding: 10px 0; + border-top: 1px solid color-mix(in srgb, var(--app-inactive) 30%, transparent); + cursor: pointer; +} +.kokio-live .a-copy-row .body { + flex: 1; + margin-right: 12px; +} +.kokio-live .a-copy-row .copy-label { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--app-inactive); + margin-bottom: 3px; +} +.kokio-live .a-copy-row .copy-value { + font-size: 13px; + color: var(--app-text); + line-height: 1.3; + font-family: var(--live-font-mono), monospace; + word-break: break-all; +} +.kokio-live .a-copy-row svg { + width: 18px; + height: 18px; + flex: none; + color: var(--app-muted-fg); +} +.kokio-live .a-copy-row.copied svg { + color: var(--app-success); +} + +.kokio-live .a-action-row { + display: flex; + gap: 8px; + margin-top: 8px; +} + +/* bottom tab bar */ +.kokio-live .a-tabbar { + display: flex; + justify-content: space-around; + align-items: center; + background: var(--app-card); + border-top: 1px solid var(--app-muted); + padding: 10px 6px 8px; +} +.kokio-live .a-tabbar-item { + all: unset; + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; + cursor: pointer; + color: var(--app-inactive); + flex: 1; +} +.kokio-live .a-tabbar-item svg { + width: 21px; + height: 21px; + stroke: currentColor; + fill: none; + stroke-width: 1.8; +} +.kokio-live .a-tabbar-item span { + font-size: 9.5px; + font-weight: 600; +} +.kokio-live .a-tabbar-item.active { + color: var(--app-primary); +} +.kokio-live .a-tabbar-item.disabled { + opacity: 0.35; + cursor: not-allowed; +} + +/* home hero "shop card" */ +.kokio-live .a-hero { + border-radius: 21px; + overflow: hidden; + background: var(--app-card); + border: 1px solid var(--app-muted); +} +.kokio-live .a-hero-imgwrap { + position: relative; + width: 100%; + aspect-ratio: 378 / 196; + overflow: hidden; + background: var(--app-card); +} +.kokio-live .a-hero-bg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0.5; +} +.kokio-live .a-hero-flagsimg { + position: relative; + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} +.kokio-live .a-hero-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px 16px; +} +.kokio-live .a-hero-title { + font-size: 16px; + font-weight: 700; + color: var(--app-card-fg); +} +.kokio-live .a-hero-sub { + font-size: 10px; + font-weight: 400; + color: var(--app-card-fg); +} +.kokio-live .a-hero-btn { + all: unset; + cursor: pointer; + background: var(--app-hero-btn-bg); + color: var(--app-hero-btn-fg); + font-size: 16px; + font-weight: 500; + padding: 5px 24px; + border-radius: 40px; +} + +/* ---------------- /live's own hero (above the ported guide) ---------------- */ +.kokio-live .live-hero { + padding: 64px 0 40px; + border-bottom: 1px solid var(--chrome-border); +} +.kokio-live .live-hero-inner { + display: flex; + flex-direction: column; + gap: 24px; + align-items: flex-start; +} +/* Kokio mark inline at the start of the "Get Kokio" heading — the whole + * row is a link back to the site's homepage, same as a logo usually is. */ +.kokio-live .hero-title-row { + display: flex; + align-items: flex-end; + gap: 14px; + text-decoration: none; + transition: opacity 0.15s; +} +.kokio-live .hero-title-row:hover { + opacity: 0.8; +} +.kokio-live .hero-title-mark { + width: 40px; + height: 40px; + flex: none; + /* align-items: flex-end on the row lines up bottoms, but the heading's + line-height leaves empty space below its actual glyphs — this pulls the + mark back up so it lands on the text's visual baseline, not its box edge. */ + margin-bottom: 23px; +} +@media (min-width: 640px) { + .kokio-live .hero-title-mark { + width: 52px; + height: 52px; + margin-bottom: 29px; + } +} + +/* iOS / Android download row — the actual App Store and Google Play + * logos (see icon-sprite.tsx), each with a small caption underneath. */ +.kokio-live .hero-icon-row { + display: flex; + align-items: center; + gap: 40px; + margin: 4px 0 2px; +} +.kokio-live .hero-icon-tile { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + text-decoration: none; + flex: none; + transition: opacity 0.15s; +} +.kokio-live .hero-icon-tile:hover { + opacity: 0.75; +} +.kokio-live .hero-icon-box { + width: 128px; + height: 128px; + border-radius: 32px; + display: flex; + align-items: center; + justify-content: center; +} +.kokio-live .hero-icon-box .hero-icon-glyph { + width: 58%; + height: 58%; +} +/* App Store's real icon presentation: the white "A" glyph on its blue + * rounded-square background, not a bare monochrome mark. */ +.kokio-live .hero-icon-box--appstore { + background: linear-gradient(160deg, #3fb6fa 0%, #0a6cff 100%); +} +.kokio-live .hero-icon-box--appstore .hero-icon-glyph { + color: #ffffff; +} +/* Play Store's real icon presentation: the tri-color triangle on its own + * white rounded-square background, same as the App Store tile beside it. */ +.kokio-live .hero-icon-box--playstore { + background: #ffffff; + border: 1px solid var(--chrome-border); +} +.kokio-live .hero-icon-caption { + font-size: 14px; + font-weight: 700; + color: var(--chrome-muted); +} +@media (max-width: 480px) { + .kokio-live .hero-icon-row { + gap: 24px; + } + .kokio-live .hero-icon-box { + width: 96px; + height: 96px; + border-radius: 24px; + } +} +.kokio-live .live-hero h1 { + font-size: clamp(32px, 6vw, 52px); + font-weight: 800; + letter-spacing: -0.01em; + margin-bottom: 14px; +} +.kokio-live .live-hero p { + max-width: 56ch; + color: var(--chrome-muted); + font-size: 17px; + line-height: 1.6; + margin: 0 0 10px; +} +.kokio-live .live-hero p:last-child { + margin-bottom: 0; +} +/* The 56ch cap above is too narrow for this longer sentence — it was + * wrapping into a ragged 3-line block with a short orphan last line. + * Let it use the hero column's own width instead. */ +.kokio-live .live-hero .hero-body { + max-width: none; +} +.kokio-live .guide-intro { + margin-top: 8px; + padding-top: 28px; + border-top: 1px solid var(--chrome-border); + width: 100%; +} +.kokio-live .guide-intro h2 { + font-size: clamp(26px, 4.5vw, 38px); + font-weight: 800; + letter-spacing: -0.01em; + margin-bottom: 8px; +} +.kokio-live .guide-intro p { + max-width: 56ch; + font-size: 15px; + color: var(--chrome-faint); + line-height: 1.5; +} +.kokio-live .store-badges { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 8px; +} +.kokio-live .store-badge { + display: inline-flex; + align-items: center; + gap: 10px; + background: var(--chrome-ink); + color: var(--chrome-bg); + border-radius: 12px; + padding: 10px 18px; + text-decoration: none; + font-weight: 700; + font-size: 14px; + transition: opacity 0.15s; +} +.kokio-live .store-badge:hover { + opacity: 0.85; +} +.kokio-live .store-badge svg { + width: 20px; + height: 20px; + flex: none; +} +.kokio-live .store-badge .lines { + display: flex; + flex-direction: column; + line-height: 1.2; +} +.kokio-live .store-badge .lines small { + font-size: 10.5px; + font-weight: 500; + opacity: 0.75; +} + +.kokio-live .sticky-download-bar { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 20; + display: flex; + gap: 10px; + padding: 10px 16px calc(10px + env(safe-area-inset-bottom)); + background: color-mix(in srgb, var(--chrome-panel) 96%, transparent); + backdrop-filter: blur(10px); + border-top: 1px solid var(--chrome-border); + transform: translateY(100%); + transition: transform 0.2s ease; +} +.kokio-live .sticky-download-bar.visible { + transform: translateY(0); +} +.kokio-live .sticky-download-bar .store-badge { + flex: 1; + justify-content: center; + padding: 10px 12px; +} +@media (min-width: 700px) { + .kokio-live .sticky-download-bar { + display: none; + } +} + +.kokio-live .handoff-divider { + display: flex; + align-items: center; + gap: 16px; + margin: 56px 0 0; + color: var(--chrome-faint); + font-family: var(--live-font-mono), monospace; + font-size: 11px; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.kokio-live .handoff-divider::before, +.kokio-live .handoff-divider::after { + content: ""; + height: 1px; + background: var(--chrome-border); + flex: 1; +} + +/* ---------------- touch targets (ticket #10 / D10) ---------------- + The artifact's segmented controls, tab strips, and icon buttons were + sized for a mouse-driven desktop demo (~26-30px tall). On an actual + touch device that's under the ~44px target Apple/WCAG recommend, so + bump hit areas here on coarse-pointer (touch) devices only — desktop + mouse users still get the artifact's exact visual sizing untouched. */ +@media (pointer: coarse) { + .kokio-live .segmented button { + min-height: 44px; + padding-top: 10px; + padding-bottom: 10px; + } + .kokio-live .rail a { + min-height: 44px; + display: inline-flex; + align-items: center; + } + .kokio-live .a-tabbar-item { + min-height: 44px; + justify-content: center; + } + .kokio-live .a-hero-btn { + min-height: 44px; + padding-top: 10px; + padding-bottom: 10px; + display: inline-flex; + align-items: center; + } + .kokio-live .a-copy-row, + .kokio-live .a-summary-row { + min-height: 44px; + } + .kokio-live input[type="checkbox"] { + min-width: 20px; + min-height: 20px; + } + .kokio-live .store-badge { + min-height: 44px; + } +} diff --git a/app/live/opengraph-image.tsx b/app/live/opengraph-image.tsx new file mode 100644 index 0000000..834a7ca --- /dev/null +++ b/app/live/opengraph-image.tsx @@ -0,0 +1,92 @@ +import fs from "node:fs"; +import path from "node:path"; +import { ImageResponse } from "next/og"; + +import { + LOGO_PATHS, + OG_IMAGE_CONTENT_TYPE, + OG_IMAGE_SIZE, +} from "@/lib/og-image"; + +export const alt = "Get Kokio"; +export const size = OG_IMAGE_SIZE; +export const contentType = OG_IMAGE_CONTENT_TYPE; + +const anybodyBold = fs.readFileSync( + path.join(process.cwd(), "assets/fonts/Anybody-Bold.ttf") +); + +export default function Image() { + return new ImageResponse( + ( +
+ + {LOGO_PATHS.map((p) => ( + + ))} + + +
+ + Get Kokio. + + + No name, no email, no KYC — just a passkey and an eSIM. + +
+ + + kokio.app/live + +
+ ), + { + ...OG_IMAGE_SIZE, + fonts: [ + { + name: "Anybody", + data: anybodyBold, + weight: 700, + style: "normal", + }, + ], + } + ); +} diff --git a/app/live/page.tsx b/app/live/page.tsx new file mode 100644 index 0000000..36ccf92 --- /dev/null +++ b/app/live/page.tsx @@ -0,0 +1,116 @@ +import { IconSprite } from "@/components/live/icon-sprite"; +import { LiveThemeProvider, ThemeToggle } from "@/components/live/theme-context"; +import { FlowProvider } from "@/components/live/flow-context"; +import { LiveHero, IOS_URL, ANDROID_URL } from "@/components/live/hero"; +import { StepSection } from "@/components/live/step-section"; +import { STEPS, SETTINGS_ABOUT, SETTINGS_CONTACT, CLOSING_TIPS } from "@/components/live/content"; +import { RichText } from "@/components/live/rich-text"; +import { ManifestoCTA } from "@/components/blog/manifesto-cta"; +import { StepAuthVisual } from "@/components/live/step-auth"; +import { StepShopVisual } from "@/components/live/step-shop"; +import { StepCheckoutVisual } from "@/components/live/step-checkout"; +import { StepOrdersVisual } from "@/components/live/step-orders"; +import { StepInstallVisual } from "@/components/live/step-install"; +import { StepWalletVisual } from "@/components/live/step-wallet"; +import { StepSettingsVisual } from "@/components/live/step-settings"; + +const STEP_VISUALS: Record = { + auth: StepAuthVisual, + shop: StepShopVisual, + checkout: StepCheckoutVisual, + orders: StepOrdersVisual, + install: StepInstallVisual, + wallet: StepWalletVisual, + settings: StepSettingsVisual, +}; + +const STEP_TRY_HINT_ICONS: Partial> = { + auth: "fingerprint", + shop: "cart", + checkout: "check", + orders: "chev-down", + install: "copy", +}; + +const RAIL_LABELS = [ + ["auth", "01 · Start"], + ["shop", "02 · Browse"], + ["checkout", "03 · Checkout"], + ["orders", "04 · Orders"], + ["install", "05 · Install"], + ["wallet", "06 · Wallet"], + ["settings", "07 · Settings"], +] as const; + +export default function LivePage() { + return ( + + + + + +
+
+ + +
+
+ +
+ {STEPS.map((step) => { + const Visual = STEP_VISUALS[step.id]; + return ( + + + + ); + })} +
+ +
+

{CLOSING_TIPS.heading}

+

{CLOSING_TIPS.lead}

+
    + {CLOSING_TIPS.items.map((item, i) => ( +
  • + + + +
  • + ))} +
+
+ +
+ +
+ + +
+
+ ); +} diff --git a/app/sitemap.ts b/app/sitemap.ts index 4de337d..0cd0322 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -5,7 +5,7 @@ import { legalConfig } from "@/config/legal"; import { GLOSSARY_UPDATED } from "@/content/glossary"; import { getAllPosts } from "@/lib/blog"; import { getManifesto } from "@/lib/manifesto"; -import { HOME_UPDATED } from "@/lib/site-copy"; +import { HOME_UPDATED, LIVE_UPDATED } from "@/lib/site-copy"; /** * Every lastModified here comes from the content itself, never from build time. @@ -30,6 +30,12 @@ export default function sitemap(): MetadataRoute.Sitemap { changeFrequency: "monthly", priority: 1, }, + { + url: new URL("/live", siteConfig.url).href, + lastModified: LIVE_UPDATED, + changeFrequency: "weekly", + priority: 0.9, + }, { // Posts are aimed at roughly every two weeks. The sitemap spec has no // value for that, and weekly is the closer of the two neighbours, so a diff --git a/components/blog/app-download-cta.tsx b/components/blog/app-download-cta.tsx index 2b3078f..34e07f9 100644 --- a/components/blog/app-download-cta.tsx +++ b/components/blog/app-download-cta.tsx @@ -1,24 +1,23 @@ import Image from "next/image"; +import Link from "next/link"; import { ArrowRight } from "lucide-react"; import Logomark from "@/assets/logomark.svg"; -const PLAY_STORE_URL = - "https://play.google.com/store/apps/details?id=app.kokio.mobile"; - -/** Download card for the Android app, dropped into blog posts that mention - * it. Mirrors ManifestoCTA's image + frosted-panel structure, but the image - * is the same beach-scene art behind the homepage hero (see components/hero.tsx) - * — clouds ~y0-600, ocean band ~y995-1120, sand + a bucket shape from ~y1120 +/** Download card dropped into blog posts that mention the app. Mirrors + * ManifestoCTA's image + frosted-panel structure, but the image is the same + * beach-scene art behind the homepage hero (see components/hero.tsx) — + * clouds ~y0-600, ocean band ~y995-1120, sand + a bucket shape from ~y1120 * down to ~y1582 (out of a 2068-tall viewBox). At the frosted panel's usual * height, object-top only shows plain sky, so the crop is pulled down to - * ~58% to land on the ocean-to-sand-and-bucket band instead. */ + * ~58% to land on the ocean-to-sand-and-bucket band instead. + * + * Points at /live (the interactive download + usage guide) rather than a + * store link directly, now that both iOS and Android are out — see D8. */ export function AppDownloadCTA() { return ( - - Koki'o on Android + Kokio on iOS & Android

- Koki'o is live on Android — iOS lands shortly. + Kokio is live.

Pick a plan that fits how you travel in over 200 countries. Pay @@ -47,13 +46,13 @@ export function AppDownloadCTA() { and secure.

- Get it on Google Play + Try out the App today! -
+ ); } diff --git a/components/blog/cta-content.ts b/components/blog/cta-content.ts index 7f7a5b8..272514a 100644 --- a/components/blog/cta-content.ts +++ b/components/blog/cta-content.ts @@ -12,7 +12,7 @@ export const CTA_CONTENT = { live: { heading: "Try Kokio", body: "Tap. Authenticate. Connect.", - ctaLabel: "Get the app", + ctaLabel: "Try out the App today!", href: APP_URL, inlineText: "Ready to travel smarter?", inlineLabel: "Try Kokio", diff --git a/components/blog/manifesto-cta.tsx b/components/blog/manifesto-cta.tsx index f075a3d..c82f8cc 100644 --- a/components/blog/manifesto-cta.tsx +++ b/components/blog/manifesto-cta.tsx @@ -21,14 +21,14 @@ export function ManifestoCTA() { />
- The Koki'o Manifesto + The Kokio Manifesto

Connectivity is a human right. Privacy is its guardian.

- Koki'o is also a provider, and we say it upfront: don't - trust Koki'o, verify it. This is our position, written out in + Kokio is also a provider, and we say it upfront: don't + trust Kokio, verify it. This is our position, written out in full.

diff --git a/components/global/announcement-banner.tsx b/components/global/announcement-banner.tsx index 959c8b8..cc42d15 100644 --- a/components/global/announcement-banner.tsx +++ b/components/global/announcement-banner.tsx @@ -1,32 +1,52 @@ +"use client"; + import React from "react"; import Image from "next/image"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; import Twitter from "@/assets/icons/twitter-fill.svg"; -import { TWITTER_URL } from "@/config/site"; -import { LAUNCH_NOTICE } from "@/lib/site-copy"; +import { CTA_MODE, TWITTER_URL } from "@/config/site"; +import { LAUNCH_NOTICE, LIVE_NOTICE } from "@/lib/site-copy"; const AnnouncementBanner = () => { + + const pathname = usePathname(); + if (pathname?.startsWith("/live")) return null; + return (
-

- {LAUNCH_NOTICE} Follow us on{" "} - - X - {" "} - for updates. -

+ {CTA_MODE === "live" ? ( +

+ {LIVE_NOTICE}{" "} + + Try out the App today! + +

+ ) : ( +

+ {LAUNCH_NOTICE} Follow us on{" "} + + X + {" "} + for updates. +

+ )}
); diff --git a/components/global/footer.tsx b/components/global/footer.tsx index 0d8ef37..2725565 100644 --- a/components/global/footer.tsx +++ b/components/global/footer.tsx @@ -10,8 +10,8 @@ import Blog from "@/assets/icons/blog.svg"; import Twitter from "@/assets/icons/twitter.svg"; import GitHub from "@/assets/icons/github.svg"; import Docs from "@/assets/icons/docs.svg"; -import { TWITTER_URL } from "@/config/site"; -import { LAUNCH_NOTICE } from "@/lib/site-copy"; +import { CTA_MODE, TWITTER_URL } from "@/config/site"; +import { LAUNCH_NOTICE, LIVE_NOTICE } from "@/lib/site-copy"; import SignUpButton from "@/components/sign-up-button"; export const socialLinks = [ @@ -77,7 +77,7 @@ const Footer = () => {

- {LAUNCH_NOTICE} Follow us on{" "} + {CTA_MODE === "live" ? LIVE_NOTICE : LAUNCH_NOTICE} Follow us on{" "} { const NavBar = () => { // /manifesto renders its own nav row inside the shared translucent panel - // (components/manifesto/manifesto-nav.tsx) instead of this global pill — see #25. + // (components/manifesto/manifesto-nav.tsx) instead of this global pill. const pathname = usePathname(); - if (pathname?.startsWith("/manifesto")) return null; + if (pathname?.startsWith("/manifesto") || pathname?.startsWith("/live")) return null; return (

diff --git a/components/live/content.ts b/components/live/content.ts new file mode 100644 index 0000000..394d356 --- /dev/null +++ b/components/live/content.ts @@ -0,0 +1,216 @@ +/** + * Voice: manifesto/blog register (plain, confident, privacy-forward). + * Every claim here has been checked against released app. + * + * `**word**` inside strings is bold-parsed by — used to call out + * exact button labels, the same way the artifact does with . + */ + +// Kokio's Telegram community — same link used in Contact Support panel and the hero's "reach out" line, kept as one source so they can't drift. +const TELEGRAM_URL = "https://t.me/+Ru38DI2V69IyY2Y9"; + +export const HERO = { + title: "Get Kokio", + body: "Onboard via biometrics, pick a plan that suits your travel, pay with your go-to option, install at home and activate once you arrive at your destination.", + contactLinkText: "reach out", + contactHref: TELEGRAM_URL, + guideHeading: "Interactive Usage Guide", + guideLead: "Flip the theme, tap and explore, a simulation of Kokio app.", +}; + +export type StepContent = { + id: string; + index: string; + sourceTag: string; + alt: 0 | 1; + title: string; + summary: string; + items: string[]; + callout?: { tone?: "default" | "danger"; strong?: string; text: string }; + tryHint?: string; +}; + +export const STEPS: StepContent[] = [ + { + id: "auth", + index: "Step 01", + sourceTag: "Sign in", + alt: 0, + title: "Onboarding", + summary: + "Using biomterics (passkeys), Kokio never asks for your name, email, or any such personal information that can be used to indetify you irl.", + items: [ + "Open Kokio, Tap **New User** create and sign-in using passkeys.", + "Only use **Existing User** if you've reinstalled Kokio on the same device without deleting that `kokio.app` passkey from device password manager?", + "After the first time, You'll just have to **Log In** continue", + ], + callout: { + strong: "Guard the passkey", + text: "A passkey synced through your platform's password manager can restore your account on a new or reinstalled device, but if the passkey itself is gone (if you delete it), or your manager doesn't sync, there's no way back in. Deleting your account (see in Step 07) is the one action that truly has no recovery, by anyone, ever.", + }, + tryHint: 'Switch to "Sign in" below, then tap a button.', + }, + { + id: "shop", + index: "Step 02", + sourceTag: "Explore", + alt: 1, + title: "Explore plans", + summary: + "Jump straight to the Shop tab. Pick a plan that suits your trip with well curated eSIM catalgoue with Local, Unlimited, Regional, Global and Special Plans", + items: [ + 'On Home, tap **Shop** or the cart icon in the tab bar, to open the shop.', + "Tap **Countries**, **Regions**, **Global**, or **Special** to browse plans in that category.", + "Every plan card shows its Days, GB, Minutes and SMS up front, next to the price. Tap **View** to open checkout and find more details about the plan there as well.", + ], + tryHint: "Tap Shop, then switch category tabs.", + }, + { + id: "checkout", + index: "Step 03", + sourceTag: "Checkout", + alt: 0, + title: "Pay however you already pay", + summary: + "Expand the plan to see exactly what you're buying, pay and install", + items: [ + "Tap the plan header, the country name and Days/GB, to expand it.", + "The expanded panel adds Plan Type, Top-Up availability, and Coverage; tap Coverage for the full list of countries and networks.", + "Check the box confirming your device is eSIM-compatible and network-enabled.", + "Pick whichever payment method you already reach for: **Credit Card**, **Apple Pay**, or **External Wallet** for crypto, via MoonPay. **Device Wallet** is coming soon.", + "Have a coupon? Fill and tap **Apply Coupon** to use it.", + "Already have an eSIM in use? Flip the **First eSIM / Top-up** toggle to **Top-up**, then tap the specific existing eSIM you want the new plan applied to, that selection is what makes it a top-up instead of a fresh purchase.", + "Once payment confirms, tap **Install eSIM** there to get the installation details (Step 05 covers it).", + ], + callout: { + text: 'Top-up only appears if you already hold a compatible eSIM, confirmed vendor-side, otherwise you\'ll see a plain "not compatible for top-up" instead of a guess.', + }, + tryHint: 'Switch to "Top-up" above to see the option.', + }, + { + id: "orders", + index: "Step 04", + sourceTag: "Orders, Payments and eSIM details", + alt: 1, + title: "Track your eSIM usage, order history, invoices and more details", + summary: + "The Orders tab follows the purchase. A separate eSIM view, reached from Home, follows the data.", + items: [ + "Open the **Orders** tab. Each order carries a status pill: Processing, Provisioning, eSIM Ready, Completed, plus a few in-between states if something needs a retry.", + "Nothing to do while it's Processing or Provisioning, give it a moment.", + "Tap an order to expand it. Once it reads **eSIM Ready**, an **Install eSIM** button appears.", + "Tap **Purchase Details** any time for a bottom sheet with ICCID, reference, payment method, invoice, and plan history.", + "For the eSIM's own status: Data Remaining, a usage bar, Ready to Install / Active / Unavailable / Deactivated, and its ICCID, check the active-eSIM card on **Home**, not the Orders tab. Different screen, same eSIM.", + ], + tryHint: "Tap any order below to expand it.", + }, + { + id: "install", + index: "Step 05", + sourceTag: "Install your eSIM", + alt: 0, + title: "Installation", + summary: + "Three tabs on the installation screen: Direct, QR, or Manual. You'll get on the fastest Direct Install first.", + items: [ + "From an order marked eSIM Ready, tap **Install eSIM**. You'll land on the **Direct** tab.", + "On **Direct**, iOS only for now, with Android support on the way, tap **Install eSIM** to hand off to Apple's native flow.", + "On **QR**, open your phone's own Settings (Cellular/Mobile Data → Add eSIM → Use QR Code) and scan it directly, or tap **Share QR code** to save or send the image somewhere you can scan it from.", + "On **Manual**, tap the **Activation code (LPA)** row to copy it to your clipboard, then paste it wherever your phone's own setup flow asks.", + "The Orders tab shows the install status too, any time you want to check.", + ], + callout: { + text: 'Kokio do not any personal info, so it cannot share this QR via email or messages to you. Share it to yourself through a channel you can open and scan, like messaging it to another device.', + }, + tryHint: "Switch tabs, or tap Copy.", + }, + { + id: "wallet", + index: "Step 06", + sourceTag: "Self custodial keyless wallet", + alt: 1, + title: "A wallet you own", + summary: + "A Device Wallet lives on your Home screen, the passkey never leaves your phone's own hardware.", + items: [ + "Before you deploy one, Home shows a **Device Wallet** card with a simple prompt: tap to create it.", + "Once deployed, that same card shows your balance and a shortened wallet address, with quick links to copy it or open it on the block explorer.", + "It's running on the Base Sepolia testnet today, ahead of mainnet.", + ], + callout: { + text: "Wallet will be enabled soon for purchases and more.", + }, + tryHint: "Tap the card below to deploy your wallet.", + }, + { + id: "settings", + index: "Step 07", + sourceTag: "Menu or settings", + alt: 0, + title: "Contact for support, About and more", + summary: + "Control the look, get support, read the real policy, and (if you ever want to) delete everything for good.", + items: [ + "Open **Settings**.", + "Tap **Privacy Policy** for kokio.app's actual policy page, loaded in place.", + "Tap **About** for the app version, what Kokio is built on, and a link to follow along.", + "Tap **Contact Support** to email or message on telegram to Kokio directly.", + "Tap the theme row to flip Dark/Light, try it below, it flips this whole guide too.", + "Tap **Logout** to end the session; you'll land back on the sign-in screen from Step 01.", + "Tap **Delete Account**, then type **DELETE** to arm the button and confirm.", + ], + callout: { + tone: "danger", + strong: "This cannot be undone.", + text: "Deleting your account is immediate. There's no recovery: not by you, not by Kokio. Any eSIM you've already paid for keeps working until it expires; deletion doesn't cancel or refund it. You lose in-app access to your order history, eSIM details, and wallet, and on-chain records can never be deleted by anyone. You'll also need to remove your passkey yourself.", + }, + }, +]; + +export const SETTINGS_ABOUT = { + lines: [ + "You are using official Kokio mobile app.", + "A mobile app to purchase eSIM data plans and subscriptions using crypto or fiat in over 200 countries.", + ], + basedOn: { + text: "Based on ", + linkLabel: "Open Source eSIM Wallet Suite", + href: "https://github.com/Blockchain-Powered-eSIM/Smart-Contract-Suite", + }, + privacyLine: + "Built with privacy first, friendly and practical design for the digital well-being and connectivity freedom of mobile users worldwide.", + website: { text: "Our website ", linkLabel: "kokio.app", href: "https://kokio.app" }, + // Only X is real (verified against source) — no Instagram link in the app. + socials: [{ label: "Follow us: @kokiodotapp", href: "https://x.com/kokiodotapp" }], +}; + +export const SETTINGS_CONTACT = { + email: "contact@kokio.app", + telegram: TELEGRAM_URL, +}; + +export const DELETE_ACCOUNT = { + title: "Delete account", + intro: + "This permanently deletes your account. It cannot be undone, not by you, and not by us. There is no recovery.", + bullets: [ + "Your eSIMs keep working. Any plan you have already paid for stays active until it expires. Deleting your account does not cancel or refund it.", + "You lose access to your order history, eSIM details, and wallet in this app.", + "On-chain records are permanent and cannot be deleted by anyone.", + "You will need to remove your passkey yourself.", + ], + confirmLabel: "Type DELETE to confirm.", +}; + +export const CLOSING_TIPS = { + heading: "Activation & troubleshooting tips", + lead: "A few things worth knowing before and after you install, not covered in the steps above.", + items: [ + "Most eSIMs install once. Remove it from your device and it cannot be installed again, so only remove one you are finished with.", + "Install on a stable connection, at home on Wi-Fi or a private hotspot, before you travel rather than on the move.", + "Label the eSIM as you install it, something like **Japan** or **Kokio**, so your device's SIM list stays easy to read.", + "On iPhone, activation runs through **Settings → Mobile Service**: pick the new eSIM, keep your primary line set for calls and iMessage, set the new eSIM for cellular data, then turn on data roaming.", + "Connectivity trouble? Toggle **Airplane Mode** off and on first, that clears most issues.", + "Still stuck? Turn off automatic network selection, pick the strongest network manually, and check the eSIM's coverage from its details in the Kokio app, since it can sometimes latch onto a weaker one.", + ], +}; diff --git a/components/live/device-frame.tsx b/components/live/device-frame.tsx new file mode 100644 index 0000000..8f20658 --- /dev/null +++ b/components/live/device-frame.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from "react"; + +export function DeviceFrame({ + sourceTag, + children, +}: { + sourceTag: string; + children: ReactNode; +}) { + return ( +
+
{sourceTag}
+
+
{children}
+
+
+ ); +} diff --git a/components/live/flow-context.tsx b/components/live/flow-context.tsx new file mode 100644 index 0000000..24795b4 --- /dev/null +++ b/components/live/flow-context.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { createContext, useContext, useState, type ReactNode } from "react"; +import type { Plan } from "@/components/live/mock-data"; + +type FlowContextValue = { + shopScreen: "home" | "shop"; + setShopScreen: (screen: "home" | "shop") => void; + selectedPlan: Plan | null; + choosePlan: (plan: Plan) => void; + completedOrderId: string | null; + completedPlan: Plan | null; + completeOrder: (plan: Plan) => void; + goTo: (stepId: string) => void; +}; + +const FlowContext = createContext(null); + +export function FlowProvider({ children }: { children: ReactNode }) { + const [shopScreen, setShopScreen] = useState<"home" | "shop">("home"); + const [selectedPlan, setSelectedPlan] = useState(null); + const [completedOrderId, setCompletedOrderId] = useState(null); + const [completedPlan, setCompletedPlan] = useState(null); + + function goTo(stepId: string) { + document.getElementById(`step-${stepId}`)?.scrollIntoView({ behavior: "smooth" }); + } + + function choosePlan(plan: Plan) { + setSelectedPlan(plan); + goTo("checkout"); + } + + function completeOrder(plan: Plan) { + // Reuse the mock "global" order slot (the one that's already eSIM + // Ready / expandable / installable) to represent "the order you just + // placed" — display fields below are swapped to match the plan that + // was actually bought. + setCompletedPlan(plan); + setCompletedOrderId("global"); + goTo("orders"); + } + + return ( + + {children} + + ); +} + +export function useFlow() { + const ctx = useContext(FlowContext); + if (!ctx) throw new Error("useFlow must be used within FlowProvider"); + return ctx; +} diff --git a/components/live/hero.tsx b/components/live/hero.tsx new file mode 100644 index 0000000..79740a3 --- /dev/null +++ b/components/live/hero.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import Logomark from "@/assets/logomark.svg"; +import { Icon } from "@/components/live/icon-sprite"; +import { HERO } from "@/components/live/content"; + +export const IOS_URL = "https://apps.apple.com/us/app/kokio/id6799362847"; +export const ANDROID_URL = "https://play.google.com/store/apps/details?id=app.kokio.mobile"; + +function trackDownload(platform: "ios" | "android") { + const w = window as unknown as { _paq?: unknown[][] }; + w._paq?.push(["trackEvent", "Live Page", "Download Click", platform]); +} + +function HeroIconRow() { + return ( + + ); +} + +function StoreBadgeLinks() { + return ( + <> + trackDownload("ios")} + > + + + Download on the + App Store + + + trackDownload("android")} + > + + + Get it on the + Play Store + + + + ); +} + +export function LiveHero() { + const [showSticky, setShowSticky] = useState(false); + + useEffect(() => { + const hero = document.querySelector(".live-hero"); + if (!hero) return; + const observer = new IntersectionObserver( + ([entry]) => setShowSticky(!entry.isIntersecting), + { rootMargin: "-1px 0px 0px 0px" } + ); + observer.observe(hero); + return () => observer.disconnect(); + }, []); + + return ( + <> +
+
+
+ + +

{HERO.title}

+ +

{HERO.body}

+

+ Feel free to{" "} + + {HERO.contactLinkText} + {" "} + for anything. +

+
+ +
+

{HERO.guideHeading}

+

{HERO.guideLead}

+
+
+
+
+ +
+ + ); +} diff --git a/components/live/icon-sprite.tsx b/components/live/icon-sprite.tsx new file mode 100644 index 0000000..1176b62 --- /dev/null +++ b/components/live/icon-sprite.tsx @@ -0,0 +1,165 @@ +import type { CSSProperties } from "react"; + +export function IconSprite() { + return ( + + ); +} + +export function Icon({ + name, + className, + style, +}: { + name: string; + className?: string; + style?: CSSProperties; +}) { + return ( + + ); +} diff --git a/components/live/mock-data.ts b/components/live/mock-data.ts new file mode 100644 index 0000000..07acc85 --- /dev/null +++ b/components/live/mock-data.ts @@ -0,0 +1,163 @@ +/** + * Simulated data for the /live guide , illustrative only, no real backend calls. + */ + +export type PlanCategory = "Countries" | "Regions" | "Global" | "Special"; + +export type Plan = { + country: string; + flag: string; + days: string; + gb: string; + price: string; +}; + +export const PLANS: Record = { + Countries: [ + { country: "Japan", flag: "🇯🇵", days: "7", gb: "5", price: "24.99" }, + { country: "France", flag: "🇫🇷", days: "15", gb: "10", price: "32.50" }, + { country: "Brazil", flag: "🇧🇷", days: "30", gb: "20", price: "44.00" }, + ], + Regions: [ + { country: "Europe", flag: "🇪🇺", days: "10", gb: "8", price: "28.00" }, + { country: "Southeast Asia", flag: "🌏", days: "15", gb: "12", price: "35.00" }, + { country: "North America", flag: "🌎", days: "7", gb: "6", price: "22.00" }, + ], + Global: [ + { country: "Global 5GB", flag: "🌐", days: "15", gb: "5", price: "26.00" }, + { country: "Global 10GB", flag: "🌐", days: "30", gb: "10", price: "42.00" }, + { country: "Global 20GB", flag: "🌐", days: "30", gb: "20", price: "58.00" }, + ], + Special: [ + { country: "3-Country Bundle", flag: "✨", days: "20", gb: "15", price: "39.00" }, + ], +}; + +export const CHECKOUT_RADIOS: { + id: string; + label: string; + sub: string; + disabled?: boolean; +}[] = [ + { id: "wallet", label: "Device Wallet", sub: "Coming soon", disabled: true }, + { id: "card", label: "Credit Card", sub: "Pay with a card" }, + { id: "apple", label: "Apple Pay", sub: "Pay with Apple Pay" }, + { id: "crypto", label: "External Wallet (via Moonpay)", sub: "Pay in crypto" }, +]; + +export const BASE_PRICE = 24.99; + +export const TOPUP_CANDIDATES = [ + { esimId: "japan-1", label: "Japan · 7 Days · 5GB" }, + { esimId: "global-1", label: "Global · 30 Days · 10GB" }, +]; + +export type OrderStatus = "Processing" | "Provisioning" | "eSIM Ready" | "Completed"; + +export type Order = { + id: string; + plan: string; + flag: string; + days: string; + gb: string; + mins: string; + sms: string; + pill: OrderStatus; + pillTone: "warning" | "success"; + expandable: boolean; + note?: string; + install?: boolean; + iccid?: string; + lpa?: string; + paymentMethod?: string; + supportRef?: string; + invoiceUrl?: string; + planHistory?: { planId: string; validity: number; purchaseDate: string }[]; +}; + +export const ORDERS: Order[] = [ + { + id: "france", + plan: "France", + flag: "🇫🇷", + days: "15", + gb: "10", + mins: "0", + sms: "0", + pill: "Processing", + pillTone: "warning", + expandable: false, + note: "No action needed while it finishes.", + }, + { + id: "global", + plan: "Global", + flag: "🌐", + days: "30", + gb: "10", + mins: "0", + sms: "0", + pill: "eSIM Ready", + pillTone: "warning", + expandable: true, + install: true, + iccid: "8988 2801 0000 4471 923", + lpa: "LPA:1$rsp.example.net$B4C5-D6E7-F8G9-H0I1", + paymentMethod: "Credit Card", + supportRef: "KOK-48291", + invoiceUrl: "#", + }, + { + id: "japan", + plan: "Japan", + flag: "🇯🇵", + days: "7", + gb: "5", + mins: "100", + sms: "50", + pill: "Completed", + pillTone: "success", + expandable: true, + install: false, + iccid: "8988 2801 0000 5512 044", + paymentMethod: "Apple Pay", + supportRef: "KOK-30144", + invoiceUrl: "#", + planHistory: [{ planId: "Japan 5GB / 7 days", validity: 7, purchaseDate: "2026-08-14" }], + }, +]; + +// The active eSIM card on Home (app/esim-detail.tsx) — a separate screen +// from Orders, per the source-accuracy correction in AppLiveTasks.md. +export const HOME_ESIM_DETAIL = { + plan: "Japan", + flag: "🇯🇵", + days: "7", + gb: "5", + remaining: "2.35 GB", + totalGb: 5, + usedPct: 47, + status: "Active" as const, + iccid: "8988 2801 0000 5512 044", +}; + +export const QR_PATTERN = [ + 1, 1, 0, 1, 0, 1, 1, 1, 0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 1, 1, + 0, 1, 1, 0, 0, 1, 0, 0, 1, 1, 1, 0, 1, 0, 1, 1, +]; + +export const MANUAL_LPA = "LPA:1$rsp.example.net$A1B2-C3D4-E5F6-G7H8"; + +export const WALLET_ADDRESS_SHORT = "0x9bf...b1e2ef7"; +export const WALLET_ADDRESS_FULL = + "0x9bfbf5000f10121edc519bdc198f2fb93e16c4fd9c20846ff837e82a8b1e2ef7"; + +export const SETTINGS_MENU = [ + { icon: "call", label: "Contact", disabled: true }, + { icon: "lock", label: "Privacy Policy", href: "https://kokio.app/privacy-policy" }, + { icon: "info", label: "About", view: "about" as const }, + { icon: "headset", label: "Contact Support", view: "contact" as const }, + { icon: "theme", toggle: true }, + { icon: "logout", label: "Logout", view: "logout" as const }, + { icon: "trash", label: "Delete Account", view: "delete" as const }, +]; diff --git a/components/live/phone-tabbar.tsx b/components/live/phone-tabbar.tsx new file mode 100644 index 0000000..4a87c91 --- /dev/null +++ b/components/live/phone-tabbar.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { Icon } from "@/components/live/icon-sprite"; + +type TabId = "home" | "shop" | "wallet" | "orders" | "settings"; + +const TABS: { id: TabId; icon: string; label: string }[] = [ + { id: "home", icon: "home", label: "Home" }, + { id: "shop", icon: "cart", label: "Shop" }, + { id: "wallet", icon: "wallet", label: "Wallet" }, + { id: "orders", icon: "receipt", label: "Orders" }, + { id: "settings", icon: "menu", label: "Settings" }, +]; + +export function PhoneTabBar({ + active, + onNavigate, +}: { + active?: TabId; + onNavigate?: (tab: TabId) => void; +}) { + return ( +
+ {TABS.map((tab) => { + const isWallet = tab.id === "wallet"; + return ( + + ); + })} +
+ ); +} diff --git a/components/live/rich-text.tsx b/components/live/rich-text.tsx new file mode 100644 index 0000000..239ab44 --- /dev/null +++ b/components/live/rich-text.tsx @@ -0,0 +1,15 @@ +import { Fragment } from "react"; + +export function RichText({ text }: { text: string }) { + const parts = text.split(/(\*\*[^*]+\*\*)/g); + return ( + <> + {parts.map((part, i) => { + if (part.startsWith("**") && part.endsWith("**")) { + return {part.slice(2, -2)}; + } + return {part}; + })} + + ); +} diff --git a/components/live/step-auth.tsx b/components/live/step-auth.tsx new file mode 100644 index 0000000..4f300d5 --- /dev/null +++ b/components/live/step-auth.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import Image from "next/image"; +import Logomark from "@/assets/logomark.svg"; +import { DeviceFrame } from "@/components/live/device-frame"; +import { useFlow } from "@/components/live/flow-context"; + +type Mode = "new" | "returning"; + +/** + * Step 01 — Sign in (components/AuthenticationModal.tsx in the mobile app). + * Confirming a button walks through a fake "Verifying… / Passkey confirmed" sequence, then resets. + */ +export function StepAuthVisual() { + const flow = useFlow(); + const [mode, setMode] = useState("new"); + const [subtext, setSubtext] = useState("Choose how to get started"); + const timers = useRef[]>([]); + + useEffect(() => () => timers.current.forEach(clearTimeout), []); + + function baseSubtext(m: Mode) { + return m === "returning" ? "Log in to continue" : "Choose how to get started"; + } + + function handleSwitchMode(m: Mode) { + setMode(m); + setSubtext(baseSubtext(m)); + } + + function confirm() { + timers.current.forEach(clearTimeout); + setSubtext("Verifying your identity…"); + timers.current.push( + setTimeout(() => setSubtext("Passkey confirmed"), 850), + // Land on Home's shop card, same as the artifact — the visitor taps + // "Shop" themselves from there to open the browsing screen. + setTimeout(() => { + flow.setShopScreen("home"); + flow.goTo("shop"); + }, 1500), + setTimeout(() => setSubtext(baseSubtext(mode)), 2600) + ); + } + + return ( + +
+
+
+ + +
+
+
+ Kokio +
+ Authentication Required +
+
+ {subtext} +
+ + {mode !== "returning" ? ( +
+ + +
+ ) : ( + + )} + +
+ +
+
+
+ No account recovery without a synced passkey. +
+ Losing the passkey itself means losing access. +
+
+
+ ); +} diff --git a/components/live/step-checkout.tsx b/components/live/step-checkout.tsx new file mode 100644 index 0000000..52fd464 --- /dev/null +++ b/components/live/step-checkout.tsx @@ -0,0 +1,380 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { DeviceFrame } from "@/components/live/device-frame"; +import { Icon } from "@/components/live/icon-sprite"; +import { CHECKOUT_RADIOS, PLANS, TOPUP_CANDIDATES } from "@/components/live/mock-data"; +import { useFlow } from "@/components/live/flow-context"; + +type CouponState = "idle" | "validating" | "valid" | "applied"; + +const DEFAULT_PLAN = PLANS.Countries[0]; + +/** + * Step 03 — Checkout & payment (checkoutHeader/CheckoutHeader.tsx + + * screens/checkout/Checkout.tsx). Reflects whichever plan was actually + * picked in Step 02, coupon validation, top-up compatibility check, and + * the Pay confirmation are all fake timers. + */ +export function StepCheckoutVisual() { + const flow = useFlow(); + const plan = flow.selectedPlan ?? DEFAULT_PLAN; + const [headerOpen, setHeaderOpen] = useState(false); + const [selectedMethod, setSelectedMethod] = useState(null); + const [coupon, setCoupon] = useState(""); + const [couponState, setCouponState] = useState("idle"); + const [priorMode, setPriorMode] = useState<"first" | "existing">("first"); + const [topupChecking, setTopupChecking] = useState(false); + const [topupSelected, setTopupSelected] = useState(null); + const [esimOk, setEsimOk] = useState(false); + const [payStatus, setPayStatus] = useState(""); + const timers = useRef[]>([]); + + useEffect(() => () => timers.current.forEach(clearTimeout), []); + + const discountApplied = couponState === "applied"; + const basePrice = Number(plan.price); + const total = (discountApplied ? basePrice - 10 : basePrice).toFixed(2); + const canPay = esimOk && !!selectedMethod; + + function handleCouponChange(value: string) { + const upper = value.toUpperCase(); + setCoupon(upper); + setCouponState("idle"); + timers.current.forEach(clearTimeout); + if (upper.length === 8) { + setCouponState("validating"); + timers.current.push( + setTimeout(() => setCouponState("valid"), 700) + ); + } + } + + function handlePriorMode(mode: "first" | "existing") { + setPriorMode(mode); + if (mode === "first") { + setTopupSelected(null); + return; + } + setTopupChecking(true); + timers.current.push(setTimeout(() => setTopupChecking(false), 700)); + } + + function handlePay() { + if (!canPay) return; + const target = TOPUP_CANDIDATES.find((r) => r.esimId === topupSelected); + setPayStatus("Preparing your payment…"); + timers.current.push( + setTimeout(() => setPayStatus("Confirm with Face ID…"), 700), + setTimeout(() => { + setPayStatus( + target + ? `Top-up of ${plan.country} · ${plan.days} Days · ${plan.gb}GB is applied to ${target.label}.` + : "Order placed — check the Orders tab." + ); + }, 1500), + // Hand off to Orders with this purchase's row pre-expanded. + setTimeout(() => flow.completeOrder(plan), 2300), + setTimeout(() => setPayStatus(""), 4500) + ); + } + + return ( + +
+ + {plan.flag} + +
+
+ +
+ {plan.country} +
+
+
+ + + {plan.days} Days + + + + {plan.gb} GB + +
+
+ + {headerOpen && ( +
+
+ Plan Type + Data +
+
+ Top-Up Options + Available +
+
+ Coverage + + 1 country + +
+
+ IP Routing + N/A +
+
+ )} +
+ +
+
+ + +
+
+ +
+
+ Payment Method +
+
+ {CHECKOUT_RADIOS.map((r) => { + const selected = selectedMethod === r.id; + return ( + + ); + })} +
+ +
Discount
+ handleCouponChange(e.target.value)} + style={{ + width: "100%", + background: "var(--app-input-bg)", + border: "1px solid transparent", + borderRadius: 12, + padding: "10px 14px", + fontSize: 14, + color: "var(--app-text)", + fontFamily: "inherit", + }} + /> +
+ {couponState === "validating" && ( + + Validating coupon… + + )} + {(couponState === "valid" || couponState === "applied") && ( +
+ {couponState === "valid" ? ( + <> + + Balance: $10.00 Kokio + + + + ) : ( + <> + Discount applied: -$10.00 + + + )} +
+ )} +
+ + {priorMode === "existing" && ( +
+ {topupChecking ? ( +
+ + Checking top-up compatibility… +
+ ) : ( +
+
+ Apply as Top-up +
+
+ Select an eSIM to top up, or leave unselected to buy a new one +
+
+ {TOPUP_CANDIDATES.map((r) => { + const selected = topupSelected === r.esimId; + return ( + + ); + })} +
+
+ )} +
+ )} + + + + +
+ {payStatus} +
+
+
+ ); +} diff --git a/components/live/step-install.tsx b/components/live/step-install.tsx new file mode 100644 index 0000000..174186f --- /dev/null +++ b/components/live/step-install.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useState } from "react"; +import { DeviceFrame } from "@/components/live/device-frame"; +import { Icon } from "@/components/live/icon-sprite"; +import { MANUAL_LPA, QR_PATTERN } from "@/components/live/mock-data"; +import { useFlow } from "@/components/live/flow-context"; + +type Tab = "QR" | "Direct" | "Manual"; + +/** + * Step 05 — Install (screens/esimInstallation/EsimInstallation.tsx). + * Tab strip order and default active tab are both **Direct** first, per + * explicit instruction — overrides the earlier QR-default accuracy note + * in AppLiveTasks.md's source-accuracy table. + * Install eSIM is always tappable and just hands off to Orders. + */ +export function StepInstallVisual() { + const flow = useFlow(); + const [tab, setTab] = useState("Direct"); + const [copied, setCopied] = useState(false); + + function copyLpa() { + try { + navigator.clipboard?.writeText(MANUAL_LPA); + } catch { + // clipboard access can be blocked — the visual "copied" state still runs + } + setCopied(true); + setTimeout(() => setCopied(false), 1200); + } + + return ( + <> + +
+ + 🇯🇵 + +
+
+ +
+ Install eSIM +
+
+
+ + + 7 Days + + + + 5 GB + +
+
+
+ +
+
+ + + +
+ + {tab === "QR" && ( +
+
+ {QR_PATTERN.map((v, i) => ( +
+ ))} +
+

+ Add it from Settings > Cellular/Mobile Data > Add eSIM > Use QR Code +

+ +
+ )} + + {tab === "Direct" && ( +
+

+ Hands off to Apple's native "Install eSIM" flow on this iPhone. +

+ +
+ )} + + {tab === "Manual" && ( +
+ +
+ )} +
+ + + ); +} diff --git a/components/live/step-orders.tsx b/components/live/step-orders.tsx new file mode 100644 index 0000000..3120340 --- /dev/null +++ b/components/live/step-orders.tsx @@ -0,0 +1,357 @@ +"use client"; + +import { useEffect, useState, type ReactNode } from "react"; +import { Icon } from "@/components/live/icon-sprite"; +import { ORDERS, HOME_ESIM_DETAIL, type Order } from "@/components/live/mock-data"; +import { useFlow } from "@/components/live/flow-context"; + +type CopyRow = { label: string; value: string; display?: string }; + +function PurchaseDetailsSheet({ order, onClose }: { order: Order; onClose: () => void }) { + const [copiedKey, setCopiedKey] = useState(null); + const rows: CopyRow[] = []; + if (order.iccid) rows.push({ label: "ICCID", value: order.iccid }); + if (order.supportRef) rows.push({ label: "Reference", value: order.supportRef }); + if (order.lpa) rows.push({ label: "LPA String", value: order.lpa, display: "Install using this code in SIM settings" }); + + function copy(key: string, value: string) { + try { + navigator.clipboard?.writeText(value); + } catch { + // clipboard access can be blocked — the visual "copied" state still runs + } + setCopiedKey(key); + setTimeout(() => setCopiedKey((k) => (k === key ? null : k)), 1200); + } + + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onClose]); + + return ( +
e.target === e.currentTarget && onClose()} + > +
+
+ +
+
+ Purchase Details + +
+
+ {rows.map((row) => ( + + ))} + {order.paymentMethod && ( +
+ Payment Method + {order.paymentMethod} +
+ )} + {order.invoiceUrl && ( +
+ Invoice + View invoice → +
+ )} + {order.planHistory?.length ? ( + <> +
+ Plan History +
+ {order.planHistory.map((entry) => ( +
+ {entry.planId} + + {entry.validity}d · {new Date(entry.purchaseDate).toLocaleDateString()} + +
+ ))} + + ) : null} +
+
+
+ ); +} + +/** Renders the order card as a real + ); +} + +function OrdersTabView() { + const flow = useFlow(); + const [openId, setOpenId] = useState(null); + const [pdOrder, setPdOrder] = useState(null); + + useEffect(() => { + if (flow.completedOrderId) setOpenId(flow.completedOrderId); + }, [flow.completedOrderId]); + + const orders = ORDERS.map((order) => + order.id === flow.completedOrderId && flow.completedPlan + ? { + ...order, + plan: flow.completedPlan.country, + flag: flow.completedPlan.flag, + days: flow.completedPlan.days, + gb: flow.completedPlan.gb, + } + : order + ); + + return ( +
+
+ {orders.map((order) => { + const expanded = openId === order.id; + return ( +
+ setOpenId(expanded ? null : order.id)} + > + + {order.flag} + +
+
+ {order.plan} +
+
+ + + {order.days} Days + + + + {order.gb} GB + + + + {order.mins} Mins + + + + {order.sms} SMS + +
+
+
+
+ + {order.pill} + + {order.expandable ? ( + + ) : order.note ? ( + + {order.note} + + ) : null} +
+ {expanded && ( +
+
+ {order.install && ( + + )} + +
+
+ )} +
+ ); + })} +
+ {pdOrder && setPdOrder(null)} />} +
+ ); +} + +function HomeEsimDetailView() { + const barColor = + HOME_ESIM_DETAIL.usedPct > 40 ? "var(--app-success)" : HOME_ESIM_DETAIL.usedPct > 15 ? "var(--app-warning)" : "var(--app-destructive)"; + return ( +
+ + {HOME_ESIM_DETAIL.flag} + +
+
+ {HOME_ESIM_DETAIL.plan} +
+
+ + + {HOME_ESIM_DETAIL.days} Days + + + + {HOME_ESIM_DETAIL.gb} GB + +
+
+
+
+ Data Remaining + {HOME_ESIM_DETAIL.remaining} +
+
+
+
+
+ Status + + {HOME_ESIM_DETAIL.status} + +
+
+ ICCID + {HOME_ESIM_DETAIL.iccid} +
+
+
+ ); +} + +/** + * Step 04 — Orders + the separate Home eSIM-detail screen. The artifact + * originally conflated these into one view; the mobile source shows + * they're two different screens (see AppLiveTasks.md accuracy table), so + * this mockup keeps them as an explicit toggle rather than pretending + * they're one expand panel. + */ +export function StepOrdersVisual() { + const [screen, setScreen] = useState<"orders" | "detail">("orders"); + + return ( +
+
Track your order
+
+
+
+
+ + +
+
+ {screen === "orders" ? : } +
+
+
+ ); +} diff --git a/components/live/step-section.tsx b/components/live/step-section.tsx new file mode 100644 index 0000000..a380bb8 --- /dev/null +++ b/components/live/step-section.tsx @@ -0,0 +1,50 @@ +import type { ReactNode } from "react"; +import { RichText } from "@/components/live/rich-text"; +import { Icon } from "@/components/live/icon-sprite"; +import type { StepContent } from "@/components/live/content"; + +export function StepSection({ + step, + tryHintIcon, + children, +}: { + step: StepContent; + tryHintIcon?: string; + children: ReactNode; +}) { + return ( +
+
+ {step.index} +

{step.title}

+

{step.summary}

+
    + {step.items.map((item, i) => ( +
  1. +

    + +

    +
  2. + ))} +
+ {step.callout && ( +
+ {step.callout.strong && {step.callout.strong} } + {step.callout.text} +
+ )} + {step.tryHint && ( +
+ {tryHintIcon && } + Try it: {step.tryHint} +
+ )} +
+
{children}
+
+ ); +} diff --git a/components/live/step-settings.tsx b/components/live/step-settings.tsx new file mode 100644 index 0000000..24987dd --- /dev/null +++ b/components/live/step-settings.tsx @@ -0,0 +1,270 @@ +"use client"; + +import { useState } from "react"; +import { DeviceFrame } from "@/components/live/device-frame"; +import { Icon } from "@/components/live/icon-sprite"; +import { useLiveTheme } from "@/components/live/theme-context"; +import { SETTINGS_MENU } from "@/components/live/mock-data"; +import { DELETE_ACCOUNT, SETTINGS_ABOUT, SETTINGS_CONTACT } from "@/components/live/content"; + +type Panel = "list" | "about" | "contact" | "delete"; + +const ABOUT_TEXT_STYLE = { + fontSize: 13.5, + lineHeight: 1.6, + color: "var(--app-text)", + opacity: 0.9, + margin: "0 0 12px", +} as const; + +/** + * Step 07 — Settings (app/(tabs)/settings.tsx + DeleteAccountModal.tsx). + */ +export function StepSettingsVisual() { + const { theme, setTheme } = useLiveTheme(); + const [panel, setPanel] = useState("list"); + const [deleteInput, setDeleteInput] = useState(""); + const [deleteStatus, setDeleteStatus] = useState(""); + const isDark = theme === "dark"; + + function resetDelete() { + setDeleteInput(""); + setDeleteStatus(""); + setPanel("list"); + } + + function confirmDelete() { + if (deleteInput.trim() !== "DELETE") return; + setDeleteStatus("Deleting your account…"); + setTimeout(() => setDeleteStatus("This is a preview — no account was deleted."), 700); + setTimeout(resetDelete, 2200); + } + + return ( + +
+ Settings +
+ + {panel === "list" && ( +
+ {SETTINGS_MENU.map((item, i) => { + const isToggle = "toggle" in item && item.toggle; + const iconName = isToggle ? (isDark ? "moon" : "sun") : item.icon; + const label = isToggle ? (isDark ? "Dark Mode" : "Light Mode") : item.label; + const disabled = "disabled" in item && item.disabled; + return ( + + ); + })} +
+ )} + + {panel === "about" && ( +
+ setPanel("list")} /> + {SETTINGS_ABOUT.lines.map((line) => ( +

+ {line} +

+ ))} +

+ {SETTINGS_ABOUT.basedOn.text} + + {SETTINGS_ABOUT.basedOn.linkLabel} + +

+

{SETTINGS_ABOUT.privacyLine}

+

+ {SETTINGS_ABOUT.website.text} + + {SETTINGS_ABOUT.website.linkLabel} + +

+
+ Follow us on socials + + {SETTINGS_ABOUT.socials.map((s) => ( + + + + ))} + +
+
+ )} + + {panel === "contact" && ( + + )} + + {panel === "delete" && ( +
+
{DELETE_ACCOUNT.title}
+

{DELETE_ACCOUNT.intro}

+
    + {DELETE_ACCOUNT.bullets.map((b) => ( +
  • + {b} +
  • + ))} +
+
{DELETE_ACCOUNT.confirmLabel}
+ setDeleteInput(e.target.value.toUpperCase())} + placeholder="DELETE" + autoCapitalize="characters" + style={{ + width: "100%", + background: "transparent", + border: "1px solid var(--app-foreground2)", + borderRadius: 14, + padding: "12px 14px", + fontSize: 15, + letterSpacing: 2, + color: "var(--app-text)", + fontFamily: "inherit", + }} + /> +
+ + +
+
+ {deleteStatus} +
+
+ )} +
+ ); +} + +function PanelHeader({ title, onClose }: { title: string; onClose: () => void }) { + return ( +
+ {title} + +
+ ); +} diff --git a/components/live/step-shop.tsx b/components/live/step-shop.tsx new file mode 100644 index 0000000..a1d9ecd --- /dev/null +++ b/components/live/step-shop.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useState } from "react"; +import { DeviceFrame } from "@/components/live/device-frame"; +import { PhoneTabBar } from "@/components/live/phone-tabbar"; +import { Icon } from "@/components/live/icon-sprite"; +import { PLANS, type PlanCategory } from "@/components/live/mock-data"; +import { useFlow } from "@/components/live/flow-context"; + +const CATEGORIES: PlanCategory[] = ["Countries", "Regions", "Global", "Special"]; + +/** Step 02 — Browse & compare (Home hero card + Shop tabs). + * The visitor here landing on the Home shop card — they tap Shop themselves + * to reach the browsing screen */ +export function StepShopVisual() { + const flow = useFlow(); + const screen = flow.shopScreen; + const setScreen = flow.setShopScreen; + const [category, setCategory] = useState("Countries"); + + return ( + +
+
+ {screen === "home" ? ( +
+
+ Home +
+
+
+ + +
+
+
+
Plan Your Next Adventure
+
The world awaits you!
+
+ +
+
+
+ ) : ( +
+
+ {CATEGORIES.map((cat) => ( + + ))} +
+ +
+ {PLANS[category].map((plan) => ( +
+ + {plan.flag} + +
+
+ {plan.country} +
+
+ + + {plan.days} Days + + + + {plan.gb} GB + +
+ +
+
+ ))} +
+
+ )} +
+ +
+ { + if (tab === "home" || tab === "shop") setScreen(tab); + }} + /> +
+
+
+ ); +} diff --git a/components/live/step-wallet.tsx b/components/live/step-wallet.tsx new file mode 100644 index 0000000..71f3ec4 --- /dev/null +++ b/components/live/step-wallet.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useState } from "react"; +import { DeviceFrame } from "@/components/live/device-frame"; +import { Icon } from "@/components/live/icon-sprite"; +import { WALLET_ADDRESS_FULL, WALLET_ADDRESS_SHORT } from "@/components/live/mock-data"; + +/** + * Step 06 — Wallet, scoped to the Home "Device Wallet" card only (the app's Wallet tab is feature-flagged off and + * unreachable, so no Send/Receive/Deposit dashboard is shown here). + */ +export function StepWalletVisual() { + const [deploying, setDeploying] = useState(false); + const [deployed, setDeployed] = useState(false); + const [copied, setCopied] = useState(false); + + function deploy() { + setDeploying(true); + setTimeout(() => { + setDeploying(false); + setDeployed(true); + }, 900); + } + + function copyAddress() { + try { + navigator.clipboard?.writeText(WALLET_ADDRESS_FULL); + } catch { + // clipboard access can be blocked — the visual "copied" state still runs + } + setCopied(true); + setTimeout(() => setCopied(false), 1200); + } + + return ( + +
Device Wallet
+
+
+ Device Wallet + +
+ + {!deployed ? ( + + ) : ( +
+
Total balance
+
+ 0 + USD +
+
+ {WALLET_ADDRESS_SHORT} + + +
+
+ )} +
+ +
+ {[ + { icon: "arrow-up", label: "Send", bg: "var(--app-warning)" }, + { icon: "arrow-down", label: "Receive", bg: "var(--app-success)" }, + { icon: "deposit", label: "Deposit", bg: "var(--app-info)" }, + ].map((item) => ( +
+ + + + {item.label} + + Coming soon + +
+ ))} +
+
+ ); +} diff --git a/components/live/theme-context.tsx b/components/live/theme-context.tsx new file mode 100644 index 0000000..12783ae --- /dev/null +++ b/components/live/theme-context.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { createContext, useContext, useState, type ReactNode } from "react"; + +type LiveTheme = "light" | "dark"; + +const ThemeContext = createContext<{ + theme: LiveTheme; + setTheme: (t: LiveTheme) => void; +}>({ theme: "light", setTheme: () => {} }); + +export function LiveThemeProvider({ children }: { children: ReactNode }) { + const [theme, setTheme] = useState("light"); + return ( + +
+ {children} +
+
+ ); +} + +export function useLiveTheme() { + return useContext(ThemeContext); +} + +export function ThemeToggle() { + const { theme, setTheme } = useLiveTheme(); + return ( +
+ Theme +
+ + +
+
+ ); +} diff --git a/components/sign-up-button.tsx b/components/sign-up-button.tsx index ecc9e30..9f9668f 100644 --- a/components/sign-up-button.tsx +++ b/components/sign-up-button.tsx @@ -1,8 +1,19 @@ "use client"; import { Button } from "@/components/ui/button"; import Link from "next/link"; +import { CTA_MODE } from "@/config/site"; const SignUpButton = () => { + if (CTA_MODE === "live") { + return ( + + + + ); + } + return (
diff --git a/lib/site-copy.ts b/lib/site-copy.ts index 7e4feb4..ed753e8 100644 --- a/lib/site-copy.ts +++ b/lib/site-copy.ts @@ -50,7 +50,7 @@ export const CANONICAL_DESCRIPTION = * there is nothing to read a date from. Bump this when the copy changes, not * when the styling does. */ -export const HOME_UPDATED = new Date("2026-09-07"); +export const HOME_UPDATED = new Date("2026-09-10"); /** * Public launch target, and the notice the banner and the footer both render. @@ -63,6 +63,16 @@ export const LAUNCH_TARGET = "September 2026"; export const LAUNCH_NOTICE = `${PRODUCT_NAME} launches in ${LAUNCH_TARGET}.`; +/** Same slot as LAUNCH_NOTICE, for once `CTA_MODE` (config/site.tsx) flips to "live". */ +export const LIVE_NOTICE = `${PRODUCT_NAME} is live on iOS and Android.`; + +/** + * When the /live page's own content last changed substantively (copy, hero, + * steps), not styling. Same rationale as HOME_UPDATED above — read by the + * sitemap so it isn't stamped with build time. + */ +export const LIVE_UPDATED = new Date("2026-09-10"); + /** Same claims, trimmed to fit a `` without truncation. */ export const META_DESCRIPTION = "Kokio is a privacy-first travel eSIM app. Data plans in over 200 destinations, paid by card or stablecoins. No KYC, no personal information collected."; diff --git a/lib/site-text.ts b/lib/site-text.ts index af847ba..555f063 100644 --- a/lib/site-text.ts +++ b/lib/site-text.ts @@ -1,4 +1,5 @@ import { featuresData } from "@/components/features"; +import { HERO, STEPS } from "@/components/live/content"; import { roadMapData } from "@/components/road-map"; import { setupData } from "@/components/setup"; import { faqsData } from "@/content/faqs"; @@ -87,6 +88,23 @@ function postSection(post: Post): string { ].join("\n\n"); } +function liveSection(): string { + return [ + `## ${HERO.title}`, + HERO.body, + STEPS.map((step) => + [ + `### ${step.index}: ${step.title}`, + step.summary, + step.items.map((item) => `- ${item}`).join("\n"), + step.callout ? `> ${step.callout.strong ? `**${step.callout.strong}** ` : ""}${step.callout.text}` : "", + ] + .filter(Boolean) + .join("\n\n") + ).join("\n\n"), + ].join("\n\n"); +} + function glossarySection(): string { return [ `Definitions of the eSIM, mobile network and wallet terms used across ${PRODUCT_NAME}.`, @@ -118,6 +136,7 @@ export function pageSections(): Record string> { return { "/": homeSection, + "/live": liveSection, "/blogs": () => blogIndexSection(posts), "/manifesto": manifestoSection, "/glossary": glossarySection, diff --git a/public/live/home-hero-flags.png b/public/live/home-hero-flags.png new file mode 100644 index 0000000..af62c2f Binary files /dev/null and b/public/live/home-hero-flags.png differ diff --git a/public/live/home-hero-sunburst.png b/public/live/home-hero-sunburst.png new file mode 100644 index 0000000..8b13353 Binary files /dev/null and b/public/live/home-hero-sunburst.png differ
+ trackDownload("ios")} + > + + + + App Store + + + trackDownload("android")} + > + + + + Play Store + +