diff --git a/sites/shkor-com/README.md b/sites/shkor-com/README.md index 3055a89..e49fc5f 100644 --- a/sites/shkor-com/README.md +++ b/sites/shkor-com/README.md @@ -1,10 +1,15 @@ # shkor.com — Shkor Labs site -Static page (`index.html`, `styles.css`, `assets/`) + email capture `subscribe.py` (stdlib, SQLite). +Static page (`index.html`, `styles.css`, `assets/`) + backend `api.py` (stdlib, SQLite): email capture and lab applications with KYC/KYB. - Preview: https://new.shkor.com (noindex) — docroot `/var/www/shkor-com`, Caddy `/etc/caddy/sites/new-shkor.caddy` - Video `assets/simbiot-harness-enterprise-en.mp4` is copied from `out/` at deploy (not committed here) -- Emails: `systemd shkor-subscribe` on 127.0.0.1:8097 → `/var/lib/shkor-subscribe/subs.db` +- Backend: `systemd shkor-api` (/opt/shkor-subscribe/api.py) on 127.0.0.1:8097, Caddy proxies `/api/*`; DB `/var/lib/shkor-subscribe/subs.db` +- Lab applications: `v2/apply/` (3-step form) → `/api/lab/apply` → portal pipeline `/api/studio/apply` (apply42.db, ref=shkor, TG to admin) + + Didit KYC session (Free KYC workflow, key from /root/coop/.env) and KYB session if a company is given. + Status page `v2/apply/status/?t=` refreshes from Didit; poller every 10 min; TG once on Approved/Declined. + KYB is OFF until `DIDIT_KYB_WORKFLOW_ID` is set in the unit (create the workflow in Didit console + top up balance). + After publishing v2 at the root, set `PUBLIC_BASE=https://shkor.com` (Didit callback). export: `sqlite3 /var/lib/shkor-subscribe/subs.db "select email,source,created_at from subs"` - v2 (labs + autopilot concept): `v2/` → https://new.shkor.com/v2/ ; design direction from ui-ux-pro-max (Dark OLED + Bento + HUD, Space Grotesk) - Update: `cp -r index.html styles.css assets /var/www/shkor-com/` (v2: `cp -r v2/* /var/www/shkor-com/v2/`) diff --git a/sites/shkor-com/api.py b/sites/shkor-com/api.py new file mode 100644 index 0000000..9dced38 --- /dev/null +++ b/sites/shkor-com/api.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +"""shkor.com backend (stdlib only): email capture + lab applications with KYC/KYB via Didit. + + POST /api/subscribe form email -> subs + POST /api/lab/apply JSON -> portal apply pipeline (42.cil.by /api/studio/apply) + Didit sessions + GET /api/lab/status?t= application + verification status (refreshed from Didit) + +Env: + SHKOR_DB sqlite path (default /var/lib/shkor-subscribe/subs.db) + PORT default 8097 + PUBLIC_BASE default https://shkor.com (used for Didit callback) + PORTAL_URL default http://127.0.0.1:8899 + DIDIT_ENV_FILE file with DIDIT_API_KEY= (default /root/coop/.env) + DIDIT_KYC_WORKFLOW_ID default: Free KYC workflow + DIDIT_KYB_WORKFLOW_ID empty -> KYB disabled (company details are still collected) + TG_ENV_FILE file with APPLY42_TOKEN= (default /root/coop/social/.env) + TG_CHAT_ID default 7569223131 +""" +import json, os, re, secrets, sqlite3, threading, time, urllib.error, urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlsplit + +DB = os.environ.get("SHKOR_DB") or os.environ.get("SUBS_DB", "/var/lib/shkor-subscribe/subs.db") +PORT = int(os.environ.get("PORT", "8097")) +PUBLIC_BASE = os.environ.get("PUBLIC_BASE", "https://shkor.com").rstrip("/") +PORTAL_URL = os.environ.get("PORTAL_URL", "http://127.0.0.1:8899").rstrip("/") +DIDIT = "https://verification.didit.me" +KYC_WF = os.environ.get("DIDIT_KYC_WORKFLOW_ID", "4efa0ea4-3ddd-4810-8aa7-5f3fbf969ff9") +KYB_WF = os.environ.get("DIDIT_KYB_WORKFLOW_ID", "") +TG_CHAT = os.environ.get("TG_CHAT_ID", "7569223131") +FINAL = {"Approved", "Declined", "Abandoned", "Expired"} +EMAIL_RE = re.compile(r"^[^@\s]{1,64}@[^@\s]+\.[A-Za-z]{2,}$") +RATE = {} # (bucket, ip) -> [timestamps] + + +def env_value(path, key): + try: + for line in open(path): + k, _, v = line.strip().partition("=") + if k.strip() == key: + return v.strip() + except OSError: + pass + return "" + + +def didit_key(): + return env_value(os.environ.get("DIDIT_ENV_FILE", "/root/coop/.env"), "DIDIT_API_KEY") + + +def db(): + c = sqlite3.connect(DB, timeout=10) + c.row_factory = sqlite3.Row + c.executescript(""" + create table if not exists subs ( + email text primary key, source text, ip text, ua text, + created_at text default (datetime('now'))); + create table if not exists lab_apps ( + token text primary key, app_id integer, portal_track text, + name text, email text, project_name text, + company_name text, company_country text, company_reg text, + ip text, created_at text default (datetime('now'))); + create table if not exists lab_checks ( + token text, kind text, session_id text, url text, status text, + notified text default '', updated_at text default (datetime('now')), + primary key (token, kind)); + """) + return c + + +def limited(bucket, ip, n, window=3600): + now = time.time() + hits = [t for t in RATE.get((bucket, ip), []) if now - t < window] + if len(hits) >= n: + return True + RATE[(bucket, ip)] = hits + [now] + return False + + +def http_json(url, data=None, headers=None, method=None, timeout=20): + body = json.dumps(data).encode() if data is not None else None + h = {"Content-Type": "application/json", **(headers or {})} + req = urllib.request.Request(url, data=body, headers=h, method=method or ("POST" if body else "GET")) + with urllib.request.urlopen(req, timeout=timeout) as r: + return json.loads(r.read().decode()) + + +def didit_session(workflow, vendor, callback): + return http_json(f"{DIDIT}/v3/session/", {"workflow_id": workflow, "vendor_data": vendor, "callback": callback}, + {"x-api-key": didit_key()}) + + +def didit_status(session_id): + return http_json(f"{DIDIT}/v3/session/{session_id}/decision/", headers={"x-api-key": didit_key()}).get("status") + + +def tg(text): + tok = env_value(os.environ.get("TG_ENV_FILE", "/root/coop/social/.env"), "APPLY42_TOKEN") + if not tok: + return + try: + http_json(f"https://api.telegram.org/bot{tok}/sendMessage", {"chat_id": TG_CHAT, "text": text}, timeout=10) + except Exception: + pass + + +def refresh(c, token, force=False): + """Pull fresh Didit statuses for unfinished checks; notify admin once per final status.""" + app = c.execute("select * from lab_apps where token=?", (token,)).fetchone() + for ch in c.execute("select * from lab_checks where token=?", (token,)).fetchall(): + if not ch["session_id"] or ch["status"] in FINAL: + continue + try: + st = didit_status(ch["session_id"]) or ch["status"] + except Exception: + continue + c.execute("update lab_checks set status=?, updated_at=datetime('now') where token=? and kind=?", + (st, token, ch["kind"])) + if st in FINAL and ch["notified"] != st: + icon = "✅" if st == "Approved" else "⛔" + tg(f"{icon} {ch['kind'].upper()} {st} — заявка в лабу #{app['app_id']}\n" + f"👤 {app['name']} · {app['email']}\n🚀 {app['project_name']}" + + (f"\n🏢 {app['company_name']}" if ch["kind"] == "kyb" else "")) + c.execute("update lab_checks set notified=? where token=? and kind=?", (st, token, ch["kind"])) + c.commit() + + +def poller(): + while True: + time.sleep(600) + try: + c = db() + toks = [r[0] for r in c.execute( + "select distinct token from lab_checks where status not in ('Approved','Declined','Abandoned','Expired') " + "and updated_at > datetime('now','-30 days')")] + for t in toks: + refresh(c, t) + c.close() + except Exception: + pass + + +def clean(d, k, n=600): + return str(d.get(k) or "").strip()[:n] + + +def lab_apply(d, ip): + if clean(d, "company_hp"): # honeypot + return 200, {"ok": True, "token": "x" * 16} + if limited("apply", ip, 3): + return 429, {"error": "Too many applications from this network — try again in an hour."} + f = {k: clean(d, k) for k in ("name", "email", "role", "project_name", "project", "links", "stage", + "b_mission", "b_market", "b_product", "b_need", + "company_name", "company_country", "company_reg")} + f["email"] = f["email"].lower() + if not f["name"] or not f["project_name"] or not f["project"]: + return 400, {"error": "Name, lab name and a one-line description are required."} + if not EMAIL_RE.match(f["email"]): + return 400, {"error": "Please enter a valid email."} + if not d.get("consent"): + return 400, {"error": "Please confirm consent to identity verification."} + has_company = bool(f["company_name"]) + links = f["links"] + if has_company: + links = (links + " | " if links else "") + \ + f"company: {f['company_name']} ({f['company_country'] or '?'}, reg {f['company_reg'] or '?'})" + payload = {"name": f["name"], "role": f["role"] or "Founder", "project_name": f["project_name"], + "project": f["project"] + (f" [stage: {f['stage']}]" if f["stage"] else ""), + "links": links[:600], "contact": f["email"], "ref": "shkor", + "b_mission": f["b_mission"], "b_market": f["b_market"], + "b_product": f["b_product"], "b_need": f["b_need"]} + try: + req = urllib.request.Request(PORTAL_URL + "/api/studio/apply", data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json", "Host": "42.cil.by", + "X-Forwarded-For": ip}, method="POST") + with urllib.request.urlopen(req, timeout=20) as r: + res = json.loads(r.read().decode()) + except urllib.error.HTTPError as e: + try: + msg = json.loads(e.read().decode()).get("error") + except Exception: + msg = None + return (429 if e.code == 429 else 502), {"error": msg or "Couldn't register the application. Please try again."} + except Exception: + return 502, {"error": "Couldn't register the application. Please try again."} + if not res.get("ok"): + return 400, {"error": res.get("error") or "Application rejected."} + + token = secrets.token_urlsafe(16) + c = db() + c.execute("insert into lab_apps(token, app_id, portal_track, name, email, project_name, company_name, " + "company_country, company_reg, ip) values (?,?,?,?,?,?,?,?,?,?)", + (token, res.get("id"), res.get("track_url") or "", f["name"], f["email"], f["project_name"], + f["company_name"], f["company_country"], f["company_reg"], ip)) + callback = f"{PUBLIC_BASE}/apply/status/?t={token}" + kinds = [("kyc", KYC_WF)] + ([("kyb", KYB_WF)] if has_company else []) + for kind, wf in kinds: + sid = url = "" + status = "Not Started" if wf else "Not Enabled" + if wf: + try: + s = didit_session(wf, f"shkor-lab:{res.get('id')}:{kind}", callback) + sid, url, status = s.get("session_id", ""), s.get("url", ""), s.get("status", "Not Started") + except Exception: + status = "Error" + c.execute("insert into lab_checks(token, kind, session_id, url, status) values (?,?,?,?,?)", + (token, kind, sid, url, status)) + c.commit() + kyc_url = c.execute("select url from lab_checks where token=? and kind='kyc'", (token,)).fetchone()["url"] + c.close() + return 200, {"ok": True, "token": token, "status_url": f"{PUBLIC_BASE}/apply/status/?t={token}", "kyc_url": kyc_url} + + +def lab_status(token): + c = db() + app = c.execute("select * from lab_apps where token=?", (token,)).fetchone() + if not app: + c.close() + return 404, {"error": "Application not found."} + refresh(c, token) + checks = {r["kind"]: {"status": r["status"], "url": r["url"] if r["status"] not in FINAL else ""} + for r in c.execute("select * from lab_checks where token=?", (token,))} + c.close() + return 200, {"ok": True, "app": {"id": app["app_id"], "name": app["name"], "project_name": app["project_name"], + "company_name": app["company_name"], "created_at": app["created_at"], + "track_url": app["portal_track"]}, + "checks": checks} + + +class H(BaseHTTPRequestHandler): + def reply(self, code, obj): + body = json.dumps(obj).encode() + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def ip(self): + return self.headers.get("X-Forwarded-For", self.client_address[0]).split(",")[0].strip() + + def body(self): + n = int(self.headers.get("Content-Length") or 0) + if n > 16384: + return None + return self.rfile.read(n).decode("utf-8", "replace") + + def do_GET(self): + u = urlsplit(self.path) + if u.path.rstrip("/") == "/api/lab/status": + t = (parse_qs(u.query).get("t") or [""])[0] + if not re.fullmatch(r"[A-Za-z0-9_-]{10,64}", t): + return self.reply(400, {"error": "Bad link."}) + return self.reply(*lab_status(t)) + self.reply(404, {"error": "not found"}) + + def do_POST(self): + path = urlsplit(self.path).path.rstrip("/") + raw = self.body() + if raw is None: + return self.reply(413, {"error": "too large"}) + if path == "/api/lab/apply": + try: + d = json.loads(raw or "{}") + except ValueError: + return self.reply(400, {"error": "bad json"}) + return self.reply(*lab_apply(d, self.ip())) + if path != "/api/subscribe": + return self.reply(404, {"error": "not found"}) + form = {k: v[0] for k, v in parse_qs(raw).items()} + if form.get("company"): # honeypot: pretend success to bots + return self.reply(200, {"ok": True}) + if limited("sub", self.ip(), 10): + return self.reply(429, {"error": "Too many attempts — try again later."}) + email = form.get("email", "").strip().lower() + if not EMAIL_RE.match(email) or len(email) > 254: + return self.reply(400, {"error": "Please enter a valid email."}) + with db() as c: + c.execute("insert or ignore into subs(email, source, ip, ua) values (?,?,?,?)", + (email, form.get("source", "")[:32], self.ip(), self.headers.get("User-Agent", "")[:300])) + self.reply(200, {"ok": True}) + + def log_message(self, fmt, *args): + pass + + +if __name__ == "__main__": + os.makedirs(os.path.dirname(DB), exist_ok=True) + db().close() + threading.Thread(target=poller, daemon=True).start() + ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever() diff --git a/sites/shkor-com/subscribe.py b/sites/shkor-com/subscribe.py deleted file mode 100644 index eab963e..0000000 --- a/sites/shkor-com/subscribe.py +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env python3 -"""shkor.com email capture: POST /api/subscribe -> SQLite. Stdlib only. - -Env: SUBS_DB (default /var/lib/shkor-subscribe/subs.db), PORT (default 8097). -Export: sqlite3 $SUBS_DB "select email,source,created_at from subs" -""" -import json, os, re, sqlite3, time -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from urllib.parse import parse_qs - -DB = os.environ.get("SUBS_DB", "/var/lib/shkor-subscribe/subs.db") -PORT = int(os.environ.get("PORT", "8097")) -EMAIL_RE = re.compile(r"^[^@\s]{1,64}@[^@\s]+\.[A-Za-z]{2,}$") -RATE = {} # ip -> [timestamps] - - -def db(): - c = sqlite3.connect(DB) - c.execute("""create table if not exists subs ( - email text primary key, source text, ip text, ua text, - created_at text default (datetime('now')))""") - return c - - -class H(BaseHTTPRequestHandler): - def reply(self, code, obj): - body = json.dumps(obj).encode() - self.send_response(code) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def do_POST(self): - if self.path.rstrip("/") != "/api/subscribe": - return self.reply(404, {"error": "not found"}) - n = int(self.headers.get("Content-Length") or 0) - if n > 4096: - return self.reply(413, {"error": "too large"}) - form = {k: v[0] for k, v in parse_qs(self.rfile.read(n).decode("utf-8", "replace")).items()} - if form.get("company"): # honeypot: pretend success to bots - return self.reply(200, {"ok": True}) - ip = self.headers.get("X-Forwarded-For", self.client_address[0]).split(",")[0].strip() - now = time.time() - hits = [t for t in RATE.get(ip, []) if now - t < 3600] - if len(hits) >= 10: - return self.reply(429, {"error": "Too many attempts — try again later."}) - RATE[ip] = hits + [now] - email = form.get("email", "").strip().lower() - if not EMAIL_RE.match(email) or len(email) > 254: - return self.reply(400, {"error": "Please enter a valid email."}) - with db() as c: - c.execute("insert or ignore into subs(email, source, ip, ua) values (?,?,?,?)", - (email, form.get("source", "")[:32], ip, self.headers.get("User-Agent", "")[:300])) - self.reply(200, {"ok": True}) - - def log_message(self, fmt, *args): - pass - - -if __name__ == "__main__": - os.makedirs(os.path.dirname(DB), exist_ok=True) - db().close() - ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever() diff --git a/sites/shkor-com/v2/apply/apply.css b/sites/shkor-com/v2/apply/apply.css new file mode 100644 index 0000000..7a527a9 --- /dev/null +++ b/sites/shkor-com/v2/apply/apply.css @@ -0,0 +1,81 @@ +/* Start-a-lab application + status pages (extends ../styles.css) */ +[hidden] { display: none !important; } +.apply { position: relative; padding: 128px 0 96px; min-height: 100svh; overflow: hidden; + background: radial-gradient(700px 500px at 0% 0%, rgba(198,255,77,.07), transparent 65%); } +.apply .grid-bg { mask-image: radial-gradient(ellipse 60% 50% at 20% 20%, #000 20%, transparent 70%); -webkit-mask-image: radial-gradient(ellipse 60% 50% at 20% 20%, #000 20%, transparent 70%); } +.apply-in { position: relative; display: grid; gap: 48px; grid-template-columns: 1fr; align-items: start; } +@media (min-width: 1000px) { .apply-in { grid-template-columns: .85fr 1.15fr; gap: 72px; } .apply-side { position: sticky; top: 112px; } } +.apply h1 { font-size: clamp(36px, 4.6vw, 58px); letter-spacing: -.035em; } +.apply-side .lead { font-size: 17px; } + +.flow { list-style: none; margin-top: 40px; display: grid; gap: 4px; } +.flow li { display: grid; grid-template-columns: 36px 1fr; gap: 14px; padding: 12px 0; border-top: 1px solid var(--line); } +.flow li span { width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center; font: 13px var(--mono); color: var(--acid); box-shadow: inset 0 0 0 1px rgba(198,255,77,.4); } +.flow b { color: var(--fg); font-weight: 600; display: block; } +.flow small { color: var(--fg-3); font-size: 14px; } +.why, .bookmark { display: grid; grid-template-columns: 24px 1fr; gap: 14px; margin-top: 32px; padding: 20px; border-radius: 16px; background: rgba(198,255,77,.05); box-shadow: inset 0 0 0 1px rgba(198,255,77,.18); font-size: 15px; } +.why svg, .bookmark svg { width: 24px; height: 24px; color: var(--acid); } +.why b, .bookmark b { color: var(--fg); } + +/* card + form */ +.apply-card { position: relative; padding: 40px; border-radius: 24px; background: linear-gradient(180deg, var(--bg-2), var(--bg-1)); box-shadow: inset 0 0 0 1px var(--line-2), 0 40px 120px rgba(0,0,0,.5); } +@media (max-width: 600px) { .apply-card { padding: 24px 20px; } } +.steps-bar { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-bottom: 32px; } +.steps-bar i { height: 3px; border-radius: 3px; background: var(--line-2); transition: background .3s var(--ease); } +.steps-bar i.on { background: var(--acid); } +fieldset { border: 0; min-width: 0; } +legend { display: block; width: 100%; font: 700 30px/1.1 var(--display); color: var(--fg); letter-spacing: -.03em; margin-bottom: 28px; } +legend .kicker { display: block; margin-bottom: 12px; } +.row2 { display: grid; gap: 0 16px; grid-template-columns: 1fr; } +@media (min-width: 640px) { .row2 { grid-template-columns: 1fr 1fr; } } +.field { display: flex; flex-direction: column; gap: 8px; margin-bottom: 20px; } +.field label, .field .label { font-size: 14px; font-weight: 600; color: var(--fg); } +.field em { font-style: normal; color: var(--acid); } +.field small { font-size: 13px; color: var(--fg-3); } +.field input, .field textarea, .field select { + width: 100%; min-height: 50px; padding: 12px 16px; border: 0; border-radius: 12px; color: var(--fg); font: 16px/1.5 var(--body); + background: rgba(255,255,255,.035); box-shadow: inset 0 0 0 1px var(--line-2); transition: box-shadow .2s var(--ease), background .2s; } +.field textarea { resize: vertical; min-height: 76px; } +.field select { appearance: none; cursor: pointer; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M5 8l5 5 5-5' fill='none' stroke='%2380869a' stroke-width='2'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 14px center; background-size: 18px; } +.field select option { background: var(--bg-2); } +.field input::placeholder, .field textarea::placeholder { color: #5f6578; } +.field input:focus, .field textarea:focus, .field select:focus { outline: none; background: rgba(255,255,255,.05); box-shadow: inset 0 0 0 1px var(--acid), 0 0 0 4px var(--acid-dim); } +.field [aria-invalid=true] { box-shadow: inset 0 0 0 1px #ff8f7f; } + +.seg { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.seg label { position: relative; cursor: pointer; } +.seg input { position: absolute; inset: 0; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; } +.seg span { display: flex; align-items: center; justify-content: center; min-height: 50px; padding: 0 12px; text-align: center; border-radius: 12px; font-size: 15px; color: var(--fg-2); box-shadow: inset 0 0 0 1px var(--line-2); transition: all .2s var(--ease); } +.seg input:checked + span { color: var(--fg); background: var(--acid-dim); box-shadow: inset 0 0 0 1px var(--acid); } +.seg input:focus-visible + span { outline: 2px solid var(--acid); outline-offset: 2px; } +.company { padding: 20px; margin: -4px 0 20px; border-radius: 16px; background: rgba(255,255,255,.02); box-shadow: inset 0 0 0 1px var(--line); } +.note { font-size: 14px; color: var(--fg-3); } + +.consent { margin-top: 8px; } +.check { display: grid; grid-template-columns: 22px 1fr; gap: 12px; font-size: 14px; color: var(--fg-2); cursor: pointer; } +.check input { width: 20px; height: 20px; margin-top: 2px; accent-color: var(--acid); cursor: pointer; } +.check a { color: var(--acid); } +.form-err { min-height: 24px; margin-top: 16px; font-size: 15px; color: #ff8f7f; } +.actions { display: flex; gap: 12px; justify-content: space-between; margin-top: 8px; } +.actions #next { margin-left: auto; } +@media (max-width: 520px) { .actions { flex-direction: column-reverse; } .actions .btn { width: 100%; } } + +/* status page */ +.status-in { position: relative; max-width: 760px; } +.timeline { list-style: none; margin-top: 48px; display: grid; gap: 0; } +.tl { position: relative; display: grid; grid-template-columns: 32px 1fr; gap: 18px; padding-bottom: 32px; } +.tl::before { content: ""; position: absolute; left: 11px; top: 26px; bottom: 0; width: 2px; background: var(--line-2); } +.tl:last-child::before { display: none; } +.tl-dot { width: 24px; height: 24px; border-radius: 50%; margin-top: 2px; box-shadow: inset 0 0 0 2px var(--line-2); background: var(--bg); position: relative; z-index: 1; } +.tl.ok .tl-dot { background: var(--acid); box-shadow: none; } +.tl.ok .tl-dot::after { content: ""; position: absolute; left: 8px; top: 5px; width: 6px; height: 10px; border: solid #0a0d02; border-width: 0 2px 2px 0; transform: rotate(45deg); } +.tl.wip .tl-dot { box-shadow: inset 0 0 0 2px var(--acid); } +.tl.wip .tl-dot::after { content: ""; position: absolute; inset: 7px; border-radius: 50%; background: var(--acid); animation: ping 1.6s var(--ease) infinite; } +.tl.bad .tl-dot { box-shadow: inset 0 0 0 2px #ff8f7f; } +.tl b { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; color: var(--fg); font: 600 19px/1.3 var(--display); letter-spacing: -.01em; } +.tl p { color: var(--fg-3); font-size: 15px; margin-top: 6px; } +.tl .btn { margin-top: 16px; } +.pill { font: 11px/1 var(--mono); letter-spacing: .08em; text-transform: uppercase; padding: 6px 10px; border-radius: 999px; box-shadow: inset 0 0 0 1px var(--line-2); color: var(--fg-3); } +.pill.ok { color: #0a0d02; background: var(--acid); box-shadow: none; } +.pill.wip { color: var(--acid); box-shadow: inset 0 0 0 1px rgba(198,255,77,.4); } +.pill.bad { color: #ff8f7f; box-shadow: inset 0 0 0 1px rgba(255,143,127,.4); } diff --git a/sites/shkor-com/v2/apply/index.html b/sites/shkor-com/v2/apply/index.html new file mode 100644 index 0000000..bddb256 --- /dev/null +++ b/sites/shkor-com/v2/apply/index.html @@ -0,0 +1,203 @@ + + + + + +Start a lab — Shkor Labs + + + + + + + + + + + + + + +
+ +
+ + + +
+

Application form

+ + +
+ +
+ Step 1 of 3About you +
+
+
+
+
+
Anything that helps us understand you and your work.
+
+ + + + + + + + + +
+ + +
+
+
+
+
+ + + + + + diff --git a/sites/shkor-com/v2/apply/status/index.html b/sites/shkor-com/v2/apply/status/index.html new file mode 100644 index 0000000..bc2ad32 --- /dev/null +++ b/sites/shkor-com/v2/apply/status/index.html @@ -0,0 +1,88 @@ + + + + + +Your application — Shkor Labs + + + + + + + + + + +
+ +
+

Your application

+

Loading…

+

+ +
    + +
    + +

    Bookmark this page. It's your private link to this application — you can come back any time to finish verification or check the status.

    +
    +
    +
    + + + + diff --git a/sites/shkor-com/v2/index.html b/sites/shkor-com/v2/index.html index 813a2fe..64c7e0e 100644 --- a/sites/shkor-com/v2/index.html +++ b/sites/shkor-com/v2/index.html @@ -30,6 +30,7 @@ Autopilot Labs Work with us + Start a lab Lab notes @@ -250,7 +251,7 @@

    For founders

    Start a lab

    Bring a business idea or an existing one. You get the autopilot, shared infrastructure, a site, payments and a community of founders running the same playbook. The lab shares a part of its revenue with the cooperative — that's the whole deal.

    - Join Cooperative 42 + Apply for a lab