shkor.com: заявки в лабу с KYC/KYB (Didit) — v2/apply + статус, api.py вместо subscribe.py
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2ecaff9f11
commit
9959c43099
7 changed files with 673 additions and 67 deletions
|
|
@ -1,10 +1,15 @@
|
||||||
# shkor.com — Shkor Labs site
|
# 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`
|
- 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)
|
- 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=<token>` 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"`
|
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)
|
- 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/`)
|
- Update: `cp -r index.html styles.css assets /var/www/shkor-com/` (v2: `cp -r v2/* /var/www/shkor-com/v2/`)
|
||||||
|
|
|
||||||
292
sites/shkor-com/api.py
Normal file
292
sites/shkor-com/api.py
Normal file
|
|
@ -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()
|
||||||
|
|
@ -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()
|
|
||||||
81
sites/shkor-com/v2/apply/apply.css
Normal file
81
sites/shkor-com/v2/apply/apply.css
Normal file
|
|
@ -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); }
|
||||||
203
sites/shkor-com/v2/apply/index.html
Normal file
203
sites/shkor-com/v2/apply/index.html
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Start a lab — Shkor Labs</title>
|
||||||
|
<meta name="description" content="Apply to start a lab at Shkor Labs: tell us about your business, verify your identity (and your company), and get the autopilot, shared infrastructure and a community of founders.">
|
||||||
|
<meta property="og:title" content="Start a lab — Shkor Labs">
|
||||||
|
<meta property="og:description" content="Apply, verify once, and start building on the autopilot.">
|
||||||
|
<meta name="theme-color" content="#050506">
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><rect width='64' height='64' rx='14' fill='%23050506'/><circle cx='32' cy='32' r='20' fill='none' stroke='%23c6ff4d' stroke-width='3' stroke-dasharray='4 6'/><circle cx='32' cy='32' r='7' fill='%23c6ff4d'/></svg>">
|
||||||
|
<link rel="preload" href="../assets/space-grotesk-latin.woff2" as="font" type="font/woff2" crossorigin>
|
||||||
|
<link rel="stylesheet" href="../styles.css">
|
||||||
|
<link rel="stylesheet" href="apply.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a class="skip" href="#main">Skip to content</a>
|
||||||
|
|
||||||
|
<header class="nav">
|
||||||
|
<div class="wrap nav-in">
|
||||||
|
<a class="brand" href="../" aria-label="Shkor Labs — home">
|
||||||
|
<svg viewBox="0 0 32 32" aria-hidden="true"><circle cx="16" cy="16" r="12" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="3 4"/><circle cx="16" cy="16" r="4.5" fill="currentColor"/></svg>
|
||||||
|
<span>Shkor<b>Labs</b></span>
|
||||||
|
</a>
|
||||||
|
<nav aria-label="Main">
|
||||||
|
<a href="../#labs">Labs</a>
|
||||||
|
<a href="../#method">Method</a>
|
||||||
|
<a class="btn btn-sm btn-ghost" href="../">Back to site</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main" class="apply">
|
||||||
|
<div class="grid-bg" aria-hidden="true"></div>
|
||||||
|
<div class="wrap apply-in">
|
||||||
|
|
||||||
|
<aside class="apply-side">
|
||||||
|
<p class="kicker">Start a lab</p>
|
||||||
|
<h1>Bring a business.<br><span class="hl">Leave with an autopilot.</span></h1>
|
||||||
|
<p class="lead">A lab is a founder plus their AI agents on shared infrastructure. Apply once, verify once — and your application lands on the cooperative's desk already trusted.</p>
|
||||||
|
|
||||||
|
<ol class="flow" aria-label="How it works">
|
||||||
|
<li><span>1</span><div><b>Tell us about the lab</b><small>5 minutes. What you're building and for whom.</small></div></li>
|
||||||
|
<li><span>2</span><div><b>Verify your identity</b><small>ID + selfie with our partner Didit. About 2 minutes, free for you.</small></div></li>
|
||||||
|
<li><span>3</span><div><b>Verify your company</b><small>Only if you have one: registry check and the people behind it.</small></div></li>
|
||||||
|
<li><span>4</span><div><b>Board decision</b><small>Verified applications are reviewed first. You'll hear back by email.</small></div></li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="why">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3 4 6v6c0 4.5 3.4 8.3 8 9 4.6-.7 8-4.5 8-9V6l-8-3Z M9 12l2 2 4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||||
|
<p><b>Why verify?</b> Labs share infrastructure, payments and revenue inside a cooperative. Knowing who's who up front is what lets us move fast — and lets every member trust every lab.</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="apply-card" aria-labelledby="form-h">
|
||||||
|
<h2 id="form-h" class="sr">Application form</h2>
|
||||||
|
<div class="steps-bar" aria-hidden="true"><i data-s="1" class="on"></i><i data-s="2"></i><i data-s="3"></i></div>
|
||||||
|
|
||||||
|
<form id="apply" novalidate>
|
||||||
|
<!-- step 1 -->
|
||||||
|
<fieldset class="step-pane" data-step="1">
|
||||||
|
<legend><span class="kicker">Step 1 of 3</span>About you</legend>
|
||||||
|
<div class="row2">
|
||||||
|
<div class="field"><label for="f-name">Full name <em>*</em></label><input id="f-name" name="name" autocomplete="name" required maxlength="120"></div>
|
||||||
|
<div class="field"><label for="f-email">Email <em>*</em></label><input id="f-email" name="email" type="email" autocomplete="email" required maxlength="254"></div>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label for="f-role">Your role</label><input id="f-role" name="role" placeholder="Founder, CEO, solo builder…" maxlength="120"></div>
|
||||||
|
<div class="field"><label for="f-links">Links</label><input id="f-links" name="links" placeholder="Website, LinkedIn, GitHub, Telegram" maxlength="400"><small>Anything that helps us understand you and your work.</small></div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- step 2 -->
|
||||||
|
<fieldset class="step-pane" data-step="2" hidden>
|
||||||
|
<legend><span class="kicker">Step 2 of 3</span>Your lab</legend>
|
||||||
|
<div class="row2">
|
||||||
|
<div class="field"><label for="f-pname">Lab / project name <em>*</em></label><input id="f-pname" name="project_name" required maxlength="80"></div>
|
||||||
|
<div class="field"><label for="f-stage">Stage</label>
|
||||||
|
<select id="f-stage" name="stage">
|
||||||
|
<option value="">Choose…</option>
|
||||||
|
<option>Idea</option><option>Prototype</option><option>First customers</option><option>Revenue</option><option>Established business</option>
|
||||||
|
</select></div>
|
||||||
|
</div>
|
||||||
|
<div class="field"><label for="f-project">In one sentence, what does it do? <em>*</em></label><input id="f-project" name="project" required maxlength="300" placeholder="We turn podcasts into vertical clips for creators"></div>
|
||||||
|
<div class="field"><label for="f-market">Who is it for?</label><textarea id="f-market" name="b_market" rows="2" maxlength="600" placeholder="Your customer, and the problem they pay to solve"></textarea></div>
|
||||||
|
<div class="field"><label for="f-need">What would you put on autopilot first?</label><textarea id="f-need" name="b_need" rows="2" maxlength="600" placeholder="Leads, support, content, reporting, operations…"></textarea></div>
|
||||||
|
<div class="field"><label for="f-mission">Why you, why now?</label><textarea id="f-mission" name="b_mission" rows="2" maxlength="600"></textarea></div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- step 3 -->
|
||||||
|
<fieldset class="step-pane" data-step="3" hidden>
|
||||||
|
<legend><span class="kicker">Step 3 of 3</span>Company & verification</legend>
|
||||||
|
<div class="field">
|
||||||
|
<span class="label">Is there a registered company behind the lab?</span>
|
||||||
|
<div class="seg" role="radiogroup" aria-label="Registered company">
|
||||||
|
<label><input type="radio" name="has_company" value="no" checked><span>Not yet</span></label>
|
||||||
|
<label><input type="radio" name="has_company" value="yes"><span>Yes, we have a company</span></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="company" class="company" hidden>
|
||||||
|
<div class="field"><label for="f-cname">Legal company name <em>*</em></label><input id="f-cname" name="company_name" maxlength="160" autocomplete="organization"></div>
|
||||||
|
<div class="row2">
|
||||||
|
<div class="field"><label for="f-ccountry">Country of registration <em>*</em></label><input id="f-ccountry" name="company_country" maxlength="60" autocomplete="country-name"></div>
|
||||||
|
<div class="field"><label for="f-creg">Registration number</label><input id="f-creg" name="company_reg" maxlength="60"></div>
|
||||||
|
</div>
|
||||||
|
<p class="note">We'll also verify the company (KYB): registry lookup, sanctions screening and the people behind it.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="consent">
|
||||||
|
<label class="check"><input type="checkbox" name="consent" required><span>I agree that Shkor Labs and its verification partner <a href="https://didit.me" target="_blank" rel="noopener">Didit</a> process my identity documents and selfie to verify who I am{{company}}. Data is used only for this application.</span></label>
|
||||||
|
</div>
|
||||||
|
<input type="text" name="company_hp" class="hp" tabindex="-1" autocomplete="off" aria-hidden="true">
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<p class="form-err" role="alert" aria-live="assertive"></p>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="btn btn-ghost" id="back" hidden>
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M16 10H5M9 5l-5 5 5 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg><span>Back</span></button>
|
||||||
|
<button type="submit" class="btn" id="next"><span>Continue</span>
|
||||||
|
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 10h11M11 5l5 5-5 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="foot">
|
||||||
|
<div class="wrap foot-in">
|
||||||
|
<p>© 2026 Shkor Labs LLC</p>
|
||||||
|
<nav aria-label="Footer"><a href="https://cil.by" rel="noopener">Cooperative 42</a><a href="mailto:alex@shkor.com">alex@shkor.com</a></nav>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var form = document.getElementById('apply'), step = 1, last = 3;
|
||||||
|
var panes = form.querySelectorAll('.step-pane'), bars = document.querySelectorAll('.steps-bar i');
|
||||||
|
var back = document.getElementById('back'), next = document.getElementById('next'), err = form.querySelector('.form-err');
|
||||||
|
var company = document.getElementById('company');
|
||||||
|
var consentText = form.querySelector('.consent span');
|
||||||
|
var consentBase = consentText.innerHTML;
|
||||||
|
|
||||||
|
function syncCompany() {
|
||||||
|
var yes = form.has_company.value === 'yes';
|
||||||
|
company.hidden = !yes;
|
||||||
|
form.company_name.required = yes; form.company_country.required = yes;
|
||||||
|
consentText.innerHTML = consentBase.replace('{{company}}', yes ? ' and my company' : '');
|
||||||
|
}
|
||||||
|
form.querySelectorAll('input[name=has_company]').forEach(function (r) { r.addEventListener('change', syncCompany); });
|
||||||
|
syncCompany();
|
||||||
|
|
||||||
|
function show(s) {
|
||||||
|
step = s;
|
||||||
|
panes.forEach(function (p) { p.hidden = +p.dataset.step !== s; });
|
||||||
|
bars.forEach(function (b) { b.classList.toggle('on', +b.dataset.s <= s); });
|
||||||
|
back.hidden = s === 1;
|
||||||
|
next.querySelector('span').textContent = s === last ? 'Submit & verify identity' : 'Continue';
|
||||||
|
err.textContent = '';
|
||||||
|
var first = form.querySelector('.step-pane[data-step="' + s + '"] input:not([type=radio]), .step-pane[data-step="' + s + '"] textarea');
|
||||||
|
if (first && s > 1) first.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function valid(s) {
|
||||||
|
var pane = form.querySelector('.step-pane[data-step="' + s + '"]'), bad = null;
|
||||||
|
pane.querySelectorAll('input, textarea, select').forEach(function (el) {
|
||||||
|
if (el.closest('[hidden]') && el.closest('[hidden]') !== pane) return;
|
||||||
|
var ok = el.checkValidity();
|
||||||
|
el.setAttribute('aria-invalid', ok ? 'false' : 'true');
|
||||||
|
if (!ok && !bad) bad = el;
|
||||||
|
});
|
||||||
|
if (bad) {
|
||||||
|
err.textContent = bad.type === 'checkbox' ? 'Please confirm consent to identity verification.'
|
||||||
|
: bad.type === 'email' ? 'Please enter a valid email.' : 'Please fill in the required fields.';
|
||||||
|
bad.focus();
|
||||||
|
}
|
||||||
|
return !bad;
|
||||||
|
}
|
||||||
|
|
||||||
|
back.addEventListener('click', function () { show(step - 1); });
|
||||||
|
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!valid(step)) return;
|
||||||
|
if (step < last) return show(step + 1);
|
||||||
|
var data = {};
|
||||||
|
new FormData(form).forEach(function (v, k) { data[k] = v; });
|
||||||
|
data.consent = form.consent.checked;
|
||||||
|
if (data.has_company !== 'yes') { data.company_name = data.company_country = data.company_reg = ''; }
|
||||||
|
next.disabled = true; next.querySelector('span').textContent = 'Submitting…';
|
||||||
|
fetch('/api/lab/apply', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
|
||||||
|
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
|
||||||
|
.then(function (res) {
|
||||||
|
if (!res.ok) throw new Error(res.j.error || 'Something went wrong.');
|
||||||
|
location.href = 'status/?t=' + encodeURIComponent(res.j.token) + '&new=1';
|
||||||
|
})
|
||||||
|
.catch(function (x) {
|
||||||
|
err.textContent = x.message || 'Network error — please try again.';
|
||||||
|
next.disabled = false; next.querySelector('span').textContent = 'Submit & verify identity';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
88
sites/shkor-com/v2/apply/status/index.html
Normal file
88
sites/shkor-com/v2/apply/status/index.html
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Your application — Shkor Labs</title>
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
|
<meta name="referrer" content="no-referrer">
|
||||||
|
<meta name="theme-color" content="#050506">
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><rect width='64' height='64' rx='14' fill='%23050506'/><circle cx='32' cy='32' r='20' fill='none' stroke='%23c6ff4d' stroke-width='3' stroke-dasharray='4 6'/><circle cx='32' cy='32' r='7' fill='%23c6ff4d'/></svg>">
|
||||||
|
<link rel="stylesheet" href="../../styles.css">
|
||||||
|
<link rel="stylesheet" href="../apply.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="nav">
|
||||||
|
<div class="wrap nav-in">
|
||||||
|
<a class="brand" href="../../" aria-label="Shkor Labs — home">
|
||||||
|
<svg viewBox="0 0 32 32" aria-hidden="true"><circle cx="16" cy="16" r="12" fill="none" stroke="currentColor" stroke-width="2" stroke-dasharray="3 4"/><circle cx="16" cy="16" r="4.5" fill="currentColor"/></svg>
|
||||||
|
<span>Shkor<b>Labs</b></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main" class="apply">
|
||||||
|
<div class="grid-bg" aria-hidden="true"></div>
|
||||||
|
<div class="wrap status-in">
|
||||||
|
<p class="kicker">Your application</p>
|
||||||
|
<h1 id="title">Loading…</h1>
|
||||||
|
<p class="lead" id="sub"></p>
|
||||||
|
|
||||||
|
<ol class="timeline" id="tl" aria-live="polite"></ol>
|
||||||
|
|
||||||
|
<div class="bookmark">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 3h12v18l-6-4-6 4V3Z" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg>
|
||||||
|
<p><b>Bookmark this page.</b> It's your private link to this application — you can come back any time to finish verification or check the status.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var t = new URLSearchParams(location.search).get('t') || '';
|
||||||
|
var fresh = new URLSearchParams(location.search).get('new');
|
||||||
|
var tl = document.getElementById('tl'), title = document.getElementById('title'), sub = document.getElementById('sub');
|
||||||
|
var LABEL = {
|
||||||
|
'Not Started': ['todo', 'Not started'], 'In Progress': ['wip', 'In progress'], 'Awaiting User': ['wip', 'Waiting for the people behind the company'],
|
||||||
|
'In Review': ['wip', 'Under review'], 'Approved': ['ok', 'Verified'], 'Declined': ['bad', 'Not approved'],
|
||||||
|
'Abandoned': ['bad', 'Abandoned — start again below'], 'Expired': ['bad', 'Link expired'],
|
||||||
|
'Not Enabled': ['wip', 'We’ll email you a link'], 'Error': ['bad', 'Couldn’t start — we’ll email you a link']
|
||||||
|
};
|
||||||
|
function esc(s) { return String(s || '').replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }
|
||||||
|
function item(state, head, body, action) {
|
||||||
|
return '<li class="tl ' + state + '"><span class="tl-dot" aria-hidden="true"></span><div><b>' + head + '</b><p>' + body + '</p>' + (action || '') + '</div></li>';
|
||||||
|
}
|
||||||
|
function check(name, c, what) {
|
||||||
|
var l = LABEL[c.status] || ['wip', c.status], go = '';
|
||||||
|
if (c.url && l[0] !== 'ok') go = '<a class="btn" href="' + esc(c.url) + '"><span>' + (c.status === 'Not Started' ? 'Verify ' + what + ' now' : 'Continue verification') + '</span><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 10h11M11 5l5 5-5 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></a>';
|
||||||
|
return item(l[0], name + ' <span class="pill ' + l[0] + '">' + esc(l[1]) + '</span>',
|
||||||
|
what === 'identity' ? 'Government ID and a quick selfie, handled by Didit. About 2 minutes.'
|
||||||
|
: 'Registry lookup, sanctions screening and the people behind the company. Each key person gets their own short link.', go);
|
||||||
|
}
|
||||||
|
function render(d) {
|
||||||
|
var a = d.app, k = d.checks || {}, all = Object.keys(k).every(function (x) { return k[x].status === 'Approved'; });
|
||||||
|
title.innerHTML = esc(a.project_name) + ' <span class="hl">#' + esc(a.id) + '</span>';
|
||||||
|
sub.textContent = all ? 'All set, ' + a.name.split(' ')[0] + '. Your verified application is with the board — we’ll be in touch by email.'
|
||||||
|
: (fresh ? 'Application received, ' + a.name.split(' ')[0] + '. One more step: verify your identity so the board can review it right away.'
|
||||||
|
: 'Here’s where your application stands.');
|
||||||
|
var h = item('ok', 'Application received', 'Submitted ' + esc(a.created_at) + ' UTC. It’s in the cooperative’s queue.');
|
||||||
|
if (k.kyc) h += check('Identity verification (KYC)', k.kyc, 'identity');
|
||||||
|
if (k.kyb) h += check('Company verification (KYB) · ' + esc(a.company_name), k.kyb, 'company');
|
||||||
|
h += item(all ? 'wip' : 'todo', 'Board review', all ? 'Verified applications are reviewed first. Usually within a few days.' : 'Starts as soon as verification is complete.');
|
||||||
|
tl.innerHTML = h;
|
||||||
|
}
|
||||||
|
function load() {
|
||||||
|
fetch('/api/lab/status?t=' + encodeURIComponent(t), { cache: 'no-store' })
|
||||||
|
.then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
|
||||||
|
.then(function (res) {
|
||||||
|
if (!res.ok) { title.textContent = 'Application not found'; sub.textContent = res.j.error || 'Check the link from your email.'; return; }
|
||||||
|
render(res.j);
|
||||||
|
})
|
||||||
|
.catch(function () { title.textContent = 'Can’t load right now'; sub.textContent = 'Please refresh in a moment.'; });
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
setInterval(function () { if (!document.hidden) load(); }, 20000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -30,6 +30,7 @@
|
||||||
<a href="#autopilot">Autopilot</a>
|
<a href="#autopilot">Autopilot</a>
|
||||||
<a href="#labs">Labs</a>
|
<a href="#labs">Labs</a>
|
||||||
<a href="#work">Work with us</a>
|
<a href="#work">Work with us</a>
|
||||||
|
<a href="apply/">Start a lab</a>
|
||||||
<a class="btn btn-sm" href="#notes">Lab notes</a>
|
<a class="btn btn-sm" href="#notes">Lab notes</a>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -250,7 +251,7 @@
|
||||||
<p class="path-k">For founders</p>
|
<p class="path-k">For founders</p>
|
||||||
<h3>Start a lab</h3>
|
<h3>Start a lab</h3>
|
||||||
<p>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.</p>
|
<p>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.</p>
|
||||||
<a class="btn btn-ghost" href="https://cil.by/join" rel="noopener"><span>Join Cooperative 42</span><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 10h11M11 5l5 5-5 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></a>
|
<a class="btn btn-ghost" href="apply/"><span>Apply for a lab</span><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4 10h11M11 5l5 5-5 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg></a>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue