new shipment viewer

This commit is contained in:
24c02 2025-01-02 14:59:16 -05:00
parent ab026ab3ae
commit 0d1d83587d
39 changed files with 1403 additions and 6653 deletions

7
.env.test Normal file
View file

@ -0,0 +1,7 @@
BASE_URL="http://localhost:9292"
AIRTABLE_PAT="patQuL5p1Q5sVw2bx.c3748cc17b0a5ac1e339d10ecb8c3e6db6a3df80ebe8cb7ebb67afdee6e37a0b"
AIRTABLE_BASE="appYYb0VQNmwlcKDV"
HSO_TABLE="tblAYIDWXg1lFnSyL"
WAREHOUSE_TABLE="tblU2bhRxqMky5SfP"
INTERNAL_KEYS=":3,>:3"
SIGNING_SECRET="this should be hard to guess"

1
.gitignore vendored
View file

@ -23,3 +23,4 @@ pnpm-debug.log*
# jetbrains setting folder # jetbrains setting folder
.idea/ .idea/
.vercel .vercel
build

15
Gemfile Normal file
View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
source "https://rubygems.org"
# gem "rails"
gem "sinatra", "~> 4.1"
gem "sinatra-contrib", "~> 4.1"
gem "norairrecord", "~> 0.1.4"
group :serve do
gem "rackup", "~> 2.2"
gem "puma", "~> 6.5"
end
gem "faraday", "~> 2.12"

70
Gemfile.lock Normal file
View file

@ -0,0 +1,70 @@
GEM
remote: https://rubygems.org/
specs:
base64 (0.2.0)
connection_pool (2.4.1)
faraday (2.12.1)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-net_http (3.4.0)
net-http (>= 0.5.0)
faraday-net_http_persistent (2.3.0)
faraday (~> 2.5)
net-http-persistent (>= 4.0.4, < 5)
json (2.9.0)
logger (1.6.2)
multi_json (1.15.0)
mustermann (3.0.3)
ruby2_keywords (~> 0.0.1)
net-http (0.6.0)
uri
net-http-persistent (4.0.5)
connection_pool (~> 2.2)
nio4r (2.7.4)
norairrecord (0.1.4)
faraday (>= 1.0, < 3.0)
faraday-net_http_persistent
net-http-persistent
puma (6.5.0)
nio4r (~> 2.0)
rack (3.1.8)
rack-protection (4.1.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
rack-session (2.0.0)
rack (>= 3.0.0)
rackup (2.2.1)
rack (>= 3)
ruby2_keywords (0.0.5)
sinatra (4.1.1)
logger (>= 1.6.0)
mustermann (~> 3.0)
rack (>= 3.0.0, < 4)
rack-protection (= 4.1.1)
rack-session (>= 2.0.0, < 3)
tilt (~> 2.0)
sinatra-contrib (4.1.1)
multi_json (>= 0.0.2)
mustermann (~> 3.0)
rack-protection (= 4.1.1)
sinatra (= 4.1.1)
tilt (~> 2.0)
tilt (2.4.0)
uri (1.0.2)
PLATFORMS
arm64-darwin-23
ruby
DEPENDENCIES
faraday (~> 2.12)
norairrecord (~> 0.1.4)
puma (~> 6.5)
rackup (~> 2.2)
sinatra (~> 4.1)
sinatra-contrib (~> 4.1)
BUNDLED WITH
2.5.17

View file

@ -1,12 +1,22 @@
# Shipment Viewer # New Shipment Viewer
This place is not a place of honor. hey! i'm glad you're here :-)
No highly esteemed deed is commemorated here. ## why?
the old one was a crusty hack on top of zach's warehouse base because it needed to exist (at that time there was no way to see what HQ sent you)
I built this because it needed to exist. this one kinda maybe looks more like actual usable maintainable software
The code is... written, I'll give it that much.
Look upon the [Marketing - Shipment Request](https://airtable.com/appK53aN0fz3sgJ4w/) base for inspiration.
## Developing the constant "why do my high seas orders not show up in there?" -> "because it doesn't have that data because this was a 1-afternoon hackjob" got really irritating so i wrote something new data sources can actually be integrated into
Fill in .env, `vercel dev`, pray. ## contributing
PLEASE
airtable creds with read-only access to mock data are in `.env.test`, if you need test cases that aren't covered included pls poke me!
`bundle install` and `dotenvx run -f .env.test -- bundle exec rackup` are probably your friends.
glhf! lmk if you have any questions while working on PRs!
## json api
`shipment-viewer.hackclub.com/dyn/jason/<email>/?signature=<signature>`

View file

@ -1,59 +0,0 @@
import {gen_shipments_url, redirect, redirect_error, EMAIL_REGEX} from "../util.js";
import {LoopsClient} from "loops";
// sorry
export const config = {
runtime: 'edge',
};
export default async function handler(req) {
if (req.method !== 'POST' || req.headers.get('content-type') !== 'application/x-www-form-urlencoded') return redirect("/", {"x-what": "huh?"})
const form = await req.formData()
const email = form.get('email"')
const internal = form.get('internal')
console.log(email)
console.log(internal)
if (internal && internal !== process.env.INTERNAL_KEY) return redirect(process.env.NOPE_URL)
if (email === "dinobox@hackclub.com") return redirect_error("that is not your email :3")
// huh??
if (!email) return redirect_error("...missing email?")
if (!EMAIL_REGEX.test(email)) return redirect_error("email isn't shaped right ¯\\_(ツ)_/¯")
try {
const apiUrl = `https://api.airtable.com/v0/${process.env.MSR_BASE}/${process.env.MSR_TABLE}?maxRecords=1&filterByFormula=${encodeURIComponent(`{Email}="${email}"`)}`;
const response = await fetch(apiUrl, {
method: 'GET',
headers: {
'Authorization': `Bearer ${process.env.AIRTABLE_API_KEY}`, // Bearer token or other credentials
'Content-Type': 'application/json',
},
});
// no such email:
if (!(await response.json()).records.length) return redirect("/?tryAgain=yeah")
} catch (e) {
console.error(e, e.stack)
return redirect_error(`error checking for shipments from that email!<br/>request ID: ${req.headers.get('x-vercel-id')}`)
}
if (internal) return redirect(await gen_shipments_url(email, true))
try {
console.log("looping")
const loops = new LoopsClient(process.env.LOOPS_API_KEY);
const loop = await loops.sendTransactionalEmail({
transactionalId: process.env.TRANSACTIONAL_ID,
email: email,
dataVariables: {
link: await gen_shipments_url(email)
}
});
if(!loop.success) {
throw new Error(loop.error)
}
} catch (e) {
console.error(e, e.stack)
return redirect_error(`error sending email!<br/>request ID: ${req.headers.get('x-vercel-id')}`)
}
return redirect("/check-your-email")
}

View file

@ -1,26 +0,0 @@
import {redirect, gen_shipments_url, EMAIL_REGEX} from "../util.js";
export const config = {
runtime: 'edge',
};
export default async function handler(req) {
if (req.method !== 'POST') return redirect(process.env.BASE_URL)
if (!process.env.PRESIGNING_KEYS.split(',').includes(req.headers.get('authorization')))
return new Response(null, {
status: 301,
headers:
{
Location: process.env.NOPE_URL,
"x-nice-try": "lol"
}
});
const email = await req.text()
if(!email || !EMAIL_REGEX.test(email)) return new Response(':-/', {status: 400})
return new Response(await gen_shipments_url(email), {
status: 200,
});
}

17
app/awawawa.rb Normal file
View file

@ -0,0 +1,17 @@
require_relative './shipment_types'
def get_shipments_for_user(email)
shipments = []
SHIPMENT_TYPES.each do |type|
shipments.concat(type.find_by_email(email))
end
shipments.sort_by{|s| s.date || "1970-01-01"}.reverse
end
def user_has_any_shipments?(email)
res = false
SHIPMENT_TYPES.each do |type|
res |= type.check_for_any_by_email email
end
res
end

14
app/build.rb Normal file
View file

@ -0,0 +1,14 @@
require_relative 'main'
STATIC_ROUTES = {
'index.html' => '/',
'internal.html' => '/internal',
'set_internal_key.html' => '/set_internal_key',
'404.html' => '/wp-admin/index.php'
}
STATIC_ROUTES.each_pair do |file, route|
response = ShipmentViewer.call('REQUEST_METHOD' => 'GET', 'PATH_INFO' => route )
body = response[2].join
File.write("build/#{file}", body)
end

20
app/loops.rb Normal file
View file

@ -0,0 +1,20 @@
require 'faraday'
def loops_send_transactional(email, transactionalId, dataVariables)
raise "no løøps API key" unless ENV['LOOPS_API_KEY']
conn = Faraday.new(url: "https://app.loops.so/") do |f|
f.request :json
f.response :raise_error
end
conn.post('https://app.loops.so/api/v1/transactional') do |req|
req.headers['Authorization'] = "Bearer #{ENV['LOOPS_API_KEY']}"
req.headers['Content-Type'] = 'application/json'
req.body = {
email:,
transactionalId:,
dataVariables:
}.to_json
end
end

110
app/main.rb Normal file
View file

@ -0,0 +1,110 @@
require 'sinatra/base'
require "sinatra/content_for"
require "sinatra/cookies"
require_relative './awawawa'
require_relative './signage'
if ENV['SEND_REAL_EMAILS']
require_relative './loops'
end
class ShipmentViewer < Sinatra::Base
helpers Sinatra::ContentFor
helpers Sinatra::Cookies
set :host_authorization, permitted_hosts: []
def gen_url(email)
"#{ENV['BASE_URL']}/dyn/shipments/#{email}?signature=#{sign(email)}"
end
def mail_out_link(email)
link = gen_url email
if ENV['SEND_REAL_EMAILS']
raise 'no transactional_id?' unless ENV['TRANSACTIONAL_ID']
loops_send_transactional(email, ENV['TRANSACTIONAL_ID'], {link:})
else
puts "[EMAIL] to: #{email}, link: #{link}"
end
end
def bounce_to_index!(message)
@error = message
halt erb :index
end
def external_link(text, href)
"<a target='_blank' href='#{href}'>#{text} <i class='fa-solid fa-arrow-up-right-from-square'></i></a>"
end
set :sessions, true
get '/' do
erb :index
end
get '/internal' do
@internal = true
erb :index
end
get '/dyn/shipments/:email' do
@show_ids = !!params[:ids]
bounce_to_index! "invalid signature...? weird...." unless params[:signature] && sig_checks_out?(params[:email], params[:signature])
@shipments = get_shipments_for_user params[:email]
erb :shipments
end
get '/dyn/jason/:email' do
content_type :json
bounce_to_index! "just what are you trying to pull?" unless params[:signature] && sig_checks_out?(params[:email], params[:signature])
@show_ids = !!params[:ids]
@shipments = get_shipments_for_user params[:email]
@shipments.to_json
end
post '/dyn/internal' do
@internal = true
unless cookies[:internal_key] && ENV["INTERNAL_KEYS"]&.split(',').include?(cookies[:internal_key])
bounce_to_index! "not the right key ya goof"
end
@shipments = get_shipments_for_user params[:email]
bounce_to_index! "couldn't find any shipments for #{params[:email]}" if @shipments.empty?
@show_ids = true
erb :shipments
end
post '/dyn/send_mail' do
bounce_to_index! "couldn't find any shipments for that email! try another?" unless params[:email] && user_has_any_shipments?(params[:email])
mail_out_link params[:email]
erb :check_ur_email
end
get '/set_internal_key' do
@internal = true
erb :set_internal_key
end
post '/api/presign' do
request.body.rewind
unless request.env["HTTP_AUTHORIZATION"] && ENV["PRESIGNING_KEYS"]&.split(',').include?(cookies[:internal_key])
bounce_to_index! "not the right key ya goof"
end
gen_url request.body.read
end
error 404 do
erb :notfound
end
error do
bounce_to_index! "env['sinatra.error'].message (request ID: #{request.env['HTTP_X_VERCEL_ID'] || "idk lol"})"
end
end

View file

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 59 KiB

583
app/public/hc.css Normal file
View file

@ -0,0 +1,583 @@
:root {
--darker: #121217;
--dark: #17171d;
--darkless: #252429;
--black: #1f2d3d;
--steel: #273444;
--slate: #3c4858;
--muted: #8492a6;
--smoke: #e0e6ed;
--snow: #f9fafc;
--white: #ffffff;
--red: #ec3750;
--orange: #ff8c37;
--yellow: #f1c40f;
--green: #33d6a6;
--cyan: #5bc0de;
--blue: #338eda;
--purple: #a633d6;
--text: var(--black);
--background: var(--white);
--elevated: var(--white);
--sheet: var(--snow);
--sunken: var(--smoke);
--border: var(--smoke);
--primary: #ec3750;
--secondary: #8492a6;
--accent: #5bc0de;
--twitter: #1da1f2;
--facebook: #3b5998;
--instagram: #e1306c;
--breakpoint-xs: 32em;
--breakpoint-s: 48em;
--breakpoint-m: 64em;
--breakpoint-l: 96em;
--breakpoint-xl: 128em;
--spacing-0: 0px;
--spacing-1: 4px;
--spacing-2: 8px;
--spacing-3: 16px;
--spacing-4: 32px;
--spacing-5: 64px;
--spacing-6: 128px;
--spacing-7: 256px;
--spacing-8: 512px;
--font-1: 12px;
--font-2: 16px;
--font-3: 20px;
--font-4: 24px;
--font-5: 32px;
--font-6: 48px;
--font-7: 64px;
--font-8: 96px;
--font-9: 128px;
--font-10: 160px;
--font-11: 192px;
--line-height-limit: 0.875;
--line-height-title: 1;
--line-height-heading: 1.125;
--line-height-subheading: 1.25;
--line-height-caption: 1.375;
--line-height-body: 1.5;
--font-weight-body: 400;
--font-weight-bold: 700;
--font-weight-heading: var(--font-weight-bold);
--letter-spacing-title: -0.009em;
--letter-spacing-headline: 0.009em;
--size-wide-plus: 2048px;
--size-wide: 1536px;
--size-layout-plus: 1200px;
--size-layout: 1024px;
--size-copy-ultra: 980px;
--size-copy-plus: 768px;
--size-copy: 680px;
--size-narrow-plus: 600px;
--size-narrow: 512px;
--radii-small: 4px;
--radii-default: 8px;
--radii-extra: 12px;
--radii-ultra: 16px;
--radii-circle: 99999px;
--shadow-text: 0 1px 2px rgba(0, 0, 0, 0.25), 0 2px 4px rgba(0, 0, 0, 0.125);
--shadow-small: 0 1px 2px rgba(0, 0, 0, 0.0625),
0 2px 4px rgba(0, 0, 0, 0.0625);
--shadow-card: 0 4px 8px rgba(0, 0, 0, 0.125);
--shadow-elevated: 0 1px 2px rgba(0, 0, 0, 0.0625),
0 8px 12px rgba(0, 0, 0, 0.125);
}
body {
font-family: "Phantom Sans", system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", Roboto, sans-serif;
line-height: var(--line-height-body);
font-weight: var(--font-weight-body);
margin: 0;
min-height: 100vh;
text-rendering: optimizeLegibility;
font-smooth: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
color: var(--text);
box-sizing: border-box;
}
* {
box-sizing: border-box;
}
.monospace {
font-family: "SF Mono", "Roboto Mono", Menlo, Consolas, monospace;
}
.heading {
font-weight: var(--font-weight-bold);
line-height: var(--line-height-heading);
margin-top: 0;
margin-bottom: 0;
}
.ultratitle {
font-weight: var(--font-weight-bold);
line-height: var(--line-height-limit);
letter-spacing: var(--letter-spacing-title);
}
.title {
font-weight: var(--font-weight-bold);
line-height: var(--line-height-title);
letter-spacing: var(--letter-spacing-title);
}
.subtitle {
margin-top: var(--spacing-3);
font-weight: var(--font-weight-body);
line-height: var(--line-height-subheading);
letter-spacing: var(--letter-spacing-headline);
}
.headline {
margin-top: var(--spacing-3);
margin-bottom: var(--spacing-3);
font-size: var(--font-4);
line-height: var(--line-height-heading);
letter-spacing: var(--letter-spacing-headline);
}
.subheadline {
margin-top: var(--spacing-0);
margin-bottom: var(--spacing-3);
font-size: var(--font-2);
line-height: var(--line-height-heading);
letter-spacing: var(--letter-spacing-headline);
}
.eyebrow {
color: var(--muted);
font-weight: var(--font-weight-heading);
letter-spacing: var(--letter-spacing-headline);
line-height: var(--line-height-subheading);
text-transform: uppercase;
margin-top: var(--spacing-0);
margin-bottom: var(--spacing-2);
}
.lead {
font-weight: var(--font-weight-body);
}
.caption {
color: var(--muted);
font-weight: var(--font-weight-body);
letter-spacing: var(--letter-spacing-headline);
line-height: var(--line-height-caption);
}
.pill {
border-radius: var(--radii-circle);
padding-left: var(--spacing-3);
padding-right: var(--spacing-3);
padding-top: var(--spacing-1);
padding-bottom: var(--spacing-1);
font-size: var(--font-2);
background: var(--primary);
color: var(--background);
font-weight: var(--font-weight-bold);
}
.outline-badge {
border-radius: var(--radii-circle);
padding-left: var(--spacing-3);
padding-right: var(--spacing-3);
padding-top: var(--spacing-1);
padding-bottom: var(--spacing-1);
font-size: var(--font-2);
background: none;
color: var(--muted);
border: 1px solid currentcolor;
font-weight: var(--font-weight-body);
}
button, input[type=submit] {
cursor: pointer;
font-family: inherit;
font-weight: var(--font-weight-bold);
border-radius: var(--radii-circle);
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: var(--shadow-card);
letter-spacing: var(--letter-spacing-headline);
-webkit-tap-highlight-color: transparent;
transition: transform 0.125s ease-in-out, box-shadow 0.125s ease-in-out;
box-sizing: border-box;
margin: 0;
min-width: 0;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
text-align: center;
line-height: inherit;
-webkit-text-decoration: none;
text-decoration: none;
padding-left: 16px;
padding-right: 16px;
padding-top: 8px;
padding-bottom: 8px;
color: var(--theme-ui-colors-white, #ffffff);
background-color: var(--theme-ui-colors-primary, #ec3750);
border: 0;
font-size: var(--font-2);
}
button:focus,
button:hover {
box-shadow: var(--shadow-elevated);
transform: scale(1.0625);
}
button.lg {
font-size: var(--font-3)!important;
line-height: var(--line-height-title);
padding-left: var(--spacing-4);
padding-right: var(--spacing-4);
padding-top: var(--spacing-3);
padding-bottom: var(--spacing-3);
}
button.outline {
background: none;
color: var(--primary);
border: 2px solid currentcolor;
}
button.cta {
font-size: var(--font-2);
background-image: radial-gradient(
ellipse farthest-corner at top left,
var(--orange),
var(--red)
);
}
.card {
padding: var(--spacing-3);
background: var(--elevated);
color: var(--text);
border-radius: var(--radii-extra);
box-shadow: var(--shadow-card);
/*overflow: hidden;*/
}
.card.sunken {
background: var(--sunken);
box-shadow: none;
}
.card.interactive {
text-decoration: none;
-webkit-tap-highlight-color: transparent;
transition: transform 0.125s ease-in-out, box-shadow 0.125s ease-in-out;
}
.card.interactive:hover,
.card.interactive:focus {
transform: scale(1.0625);
box-shadow: var(--shadow-elevated);
}
input,
textarea,
select {
background: var(--elevated);
color: var(--text);
font-family: inherit;
border-radius: var(--radii-small);
border: 0;
font-size: inherit;
padding: var(--spacing-2);
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
input::-webkit-input-placeholder,
input::-moz-placeholder,
input:-ms-input-placeholder,
textarea::-webkit-input-placeholder,
textarea::-moz-placeholder,
textarea:-ms-input-placeholder,
select::-webkit-input-placeholder,
select::-moz-placeholder,
select:-ms-input-placeholder {
color: var(--muted);
}
input[type="search"]::-webkit-search-decoration,
textarea[type="search"]::-webkit-search-decoration,
select[type="search"]::-webkit-search-decoration {
display: none;
}
input[type="checkbox"] {
-webkit-appearance: checkbox;
-moz-appearance: checkbox;
appearance: checkbox;
}
label {
color: var(--text);
display: flex;
flex-direction: column;
text-align: left;
line-height: var(--line-height-caption);
font-size: var(--font-3);
}
label.horizontal {
display: flex;
}
.slider {
color: var(--primary);
}
.form-hidden {
position: absolute;
height: 1px;
width: 1px;
clip: rect(1px, 1px, 1px, 1px);
white-space: nowrap;
}
.container {
width: 100%;
margin: auto;
padding-left: var(--spacing-3);
padding-right: var(--spacing-3);
}
h1 {
font-size: var(--font-5);
font-weight: var(--font-weight-bold);
line-height: var(--line-height-heading);
margin-top: 0;
margin-bottom: 0;
}
h2 {
font-size: var(--font-4);
font-weight: var(--font-weight-bold);
line-height: var(--line-height-heading);
margin-top: 0;
margin-bottom: 0;
}
h3 {
font-size: var(--font-3);
font-weight: var(--font-weight-bold);
line-height: var(--line-height-heading);
margin-top: 0;
margin-bottom: 0;
}
h4 {
font-size: var(--font-2);
font-weight: var(--font-weight-bold);
line-height: var(--line-height-heading);
margin-top: 0;
margin-bottom: 0;
}
h5 {
font-size: var(--font-1);
font-weight: var(--font-weight-bold);
line-height: var(--line-height-heading);
margin-top: 0;
margin-bottom: 0;
}
h6 {
font-weight: var(--font-weight-bold);
line-height: var(--line-height-heading);
margin-top: 0;
margin-bottom: 0;
}
p {
color: var(--text);
font-weight: var(--font-weight-body);
line-height: var(--line-height-body);
margin-top: var(--spacing-3);
margin-bottom: var(--spacing-3);
}
img {
max-width: 100%;
}
hr {
border: 0;
border-bottom: 1px solid var(--border);
}
a {
color: var(--primary);
text-decoration: underline;
text-underline-position: under;
}
a:focus,
a:hover {
text-decoration-style: wavy;
text-decoration-skip-ink: none;
}
pre {
font-family: "SF Mono", "Roboto Mono", Menlo, Consolas, monospace;
font-size: var(--font-1);
padding: var(--spacing-3);
color: var(--text);
background: var(--sunken);
overflow: auto;
border-radius: var(--radii-default);
white-space: inherit;
}
pre > code {
color: inherit;
margin-left: 0;
margin-right: 0;
padding-left: 0;
padding-right: 0;
}
code {
font-family: "SF Mono", "Roboto Mono", Menlo, Consolas, monospace;
font-size: inherit;
color: var(--purple);
background: var(--sunken);
overflow: auto;
border-radius: var(--radii-small);
margin-left: var(--spacing-1);
margin-right: var(--spacing-1);
padding-left: var(--spacing-1);
padding-right: var(--spacing-1);
}
p > code,
li > code {
color: var(--blue);
font-size: 0.875em;
}
p > a > code,
li > a > code {
color: var(--blue);
font-size: 0.875em;
}
li {
margin-top: var(--spacing-2);
margin-bottom: var(--spacing-2);
}
table {
width: 100%;
margin-top: var(--spacing-4);
margin-bottom: var(--spacing-4);
border-collapse: separate;
border-spacing: 0;
}
table > th,
table > td {
text-align: left;
padding: 4px;
padding-left: 0px;
border-color: var(--border);
border-bottom-style: solid;
}
th {
vertical-align: bottom;
border-bottom-width: 2px;
}
td {
vertical-align: top;
border-bottom-width: 1px;
}
@media screen and (min-width: 32em) {
.ultratitle {
font-size: var(--font-5);
}
.title {
font-size: var(--font-4);
}
.subtitle {
font-size: var(--font-2);
}
.eyebrow {
font-size: var(--font-3);
}
.lead {
font-size: var(--font-2);
margin-top: var(--spacing-2);
margin-bottom: var(--spacing-2);
}
.card {
padding: var(--spacing-3);
}
.container {
max-width: var(--size-layout);
}
.container.copy {
max-width: var(--size-copy);
}
.container.narrow {
max-width: var(--size-narrow);
}
}
@media screen and (min-width: 48em) {
.ultratitle {
font-size: var(--font-6);
}
.title {
font-size: var(--font-5);
}
.subtitle {
font-size: var(--font-3);
}
.eyebrow {
font-size: var(--font-4);
}
.lead {
font-size: var(--font-3);
margin-top: var(--spacing-3);
margin-bottom: var(--spacing-3);
}
}
@media screen and (min-width: 64em) {
.ultratitle {
font-size: var(--font-7);
}
.title {
font-size: var(--font-6);
}
.container {
max-width: var(--size-layout-plus);
}
.container.wide {
max-width: var(--size-wide);
}
.container.copy {
max-width: var(--size-copy-plus);
}
.container.narrow {
max-width: var(--size-narrow-plus);
}
}

View file

@ -1,3 +1,19 @@
.shipments {
layout: masonry;
}
/*@media (min-width: 480px) {*/
/* .shipments {*/
/* grid-template-columns: 1fr 1fr;*/
/* }*/
/*}*/
/*@media (min-width: 768px) {*/
/* .shipments {*/
/* grid-template-columns: 1fr 1fr 1fr;*/
/* }*/
/*}*/
.banner img { .banner img {
height: 80px; height: 80px;
@ -41,4 +57,23 @@ a:focus,
a:hover { a:hover {
text-decoration-style: wavy; text-decoration-style: wavy;
text-decoration-skip-ink: none; text-decoration-skip-ink: none;
}
.navbar {
z-index: 42069;
/*overflow: hidden;*/
background-color: var(--sunken);
position: fixed; /* Set the navbar to fixed position */
top: 0; /* Position the navbar at the top of the page */
width: 100%; /* Full width */
}
.navbar-brand {
margin-left: 140px;
color: black;
float: left;
display: block;
text-align: center;
padding: 14px 16px;
text-decoration: none;
} }

326
app/shipment_types.rb Normal file
View file

@ -0,0 +1,326 @@
if ENV['LEMME_MITM']
require 'openssl'
OpenSSL::SSL::VERIFY_PEER = OpenSSL::SSL::VERIFY_NONE
I_KNOW_THAT_OPENSSL_VERIFY_PEER_EQUALS_VERIFY_NONE_IS_WRONG = nil
end
require 'norairrecord'
Norairrecord.api_key = ENV["AIRTABLE_PAT"]
Norairrecord.user_agent = "shipment viewer"
Norairrecord.base_url = ENV["AIRTABLE_BASE_URL"] if ENV["AIRTABLE_BASE_URL"]
class Shipment < Norairrecord::Table
class << self
def base_key
ENV["AIRTABLE_BASE"];
end
attr_accessor :email_column
def find_by_email(email)
raise ArgumentError, "no email?" if email.nil? || email.empty?
records :filter => "LOWER({#{self.email_column}})='#{email.downcase}'"
end
def check_for_any_by_email(email)
raise ArgumentError, "no email?" if email.nil? || email.empty?
records(:filter => "LOWER({#{self.email_column}})='#{email.downcase}'", fields: [], max_records: 1).any?
end
end
def date
raise NotImplementedError
end
def tracking_number
nil
end
def tracking_link
nil
end
def status_text
"error fetching status! poke nora"
end
def source_url
fields["source_rec_url"]["url"]
end
def source_id
source_url.split('/').last
end
def icon
"📦"
end
def hide_contents?
false
end
def status_icon
"?"
end
def shipped?
nil
end
def description
nil
end
def to_json(options = {})
{
id:,
date:,
tracking_link:,
tracking_number:,
type: self.class.name,
type_text:,
title: title_text,
shipped: shipped?,
icon:,
description:,
source_record: source_url
}.compact.to_json
end
end
class WarehouseShipment < Shipment
self.table_name = ENV["WAREHOUSE_TABLE"]
self.email_column = "Email"
def type_text
"Warehouse shipment"
end
def title_text
fields["user_facing_title"] || fields["Request Type"].join(', ')
end
def date
self["Date Requested"]
end
def status_text
case fields["state"]
when "dispatched"
"sent to warehouse..."
when "mailed"
"shipped!"
else
"this shouldn't happen."
end
end
def status_icon
case fields["state"]
when "dispatched"
'<i class="fa-solid fa-dolly"></i>'
when "mailed"
'<i class="fa-solid fa-truck-fast"></i>'
else
'<i class="fa-solid fa-clock"></i>'
end
end
def tracking_link
fields["WarehouseTracking URL"]
end
def tracking_number
fields["WarehouseTracking Number"] unless fields["WarehouseTracking Number"] == "Not Provided"
end
def hide_contents?
fields["surprise"]
end
def icon
return "🎁" if hide_contents? || title_text.start_with?("High Seas Free")
return "✉️" if fields["WarehouseService"]&.include?("First Class")
"📦"
end
def shipped?
fields["state"] == 'mailed'
end
def description
return "it's a surprise!" if hide_contents?
begin
puts "awa#{source_id}"
fields['user_facing_description'] ||
fields["WarehouseItems Shipped JSON"] && JSON.parse(fields["WarehouseItems Shipped JSON"]).select {|item| (item["quantity"]&.to_i || 0) > 0}.map do |item|
"#{item["quantity"]}x #{item["name"]}"
end
rescue JSON::ParserError
"error parsing JSON for #{source_id}!"
end
end
end
class HighSeasShipment < Shipment
self.table_name = ENV["HSO_TABLE"]
self.email_column = "recipient:email"
def type_text
"High Seas order"
end
def title_text
"High Seas #{fields["shop_item:name"] || "unknown?!"}"
end
def date
self["created_at"]
end
has_subtypes "shop_item:fulfillment_type", {
["minuteman"] => "HSMinutemanShipment",
["hq_mail"] => "HSHQMailShipment",
["third_party_physical"] => "HS3rdPartyPhysicalShipment",
["agh"] => "HSRawPendingAGHShipment",
["agh_random_stickers"] => "HSRawPendingAGHShipment",
}
def status_text
case fields["status"]
when "PENDING_MANUAL_REVIEW"
"awaiting manual review..."
when "AWAITING_YSWS_VERIFICATION"
"waiting for you to get verified..."
when "pending_nightly"
"we'll send it out when we can!"
when "fulfilled"
["sent!", "mailed!", "on its way!"].sample
else
super
end
end
def status_icon
case fields["status"]
when "PENDING_MANUAL_REVIEW"
'<i class="fa-solid fa-hourglass-half"></i>'
when "AWAITING_YSWS_VERIFICATION"
'<i class="fa-solid fa-user-clock"></i>'
when "pending_nightly"
'<i class="fa-solid fa-clock"></i>'
when "fulfilled"
'<i class="fa-solid fa-truck-fast"></i>'
end
end
def tracking_number
fields["tracking_number"]
end
def tracking_link
tracking_number && "https://parcelsapp.com/en/tracking/#{tracking_number}"
end
def icon
return "🎁" if fields["shop_item:name"]&.start_with? "Free"
super
end
def shipped?
fields['status'] == 'fulfilled'
end
end
class HSMinutemanShipment < HighSeasShipment
def status_text
case fields["status"]
when "pending_nightly"
"will go out in next week's batch..."
when "fulfilled"
"has gone out/will go out over the next week!"
else
super
end
end
def status_icon
case fields["status"]
when "pending_nightly"
'<i class="fa-solid fa-envelopes-bulk"></i>'
when "fulfilled"
'<i class="fa-solid fa-envelope-circle-check"></i>'
else
super
end
end
def icon
"💌"
end
end
class HSHQMailShipment < HighSeasShipment
def type_text
"High Seas shipment (from HQ)"
end
def status_text
case fields["status"]
when "pending_nightly"
["we'll ship it when we can!", "will be sent when dinobox gets around to it"].sample
else
super
end
end
def status_icon
case fields["status"]
when "fulfilled"
'<i class="fa-solid fa-truck"></i>'
else
super
end
end
end
class HS3rdPartyPhysicalShipment < HighSeasShipment
def type_text
"High Seas 3rd-party physical"
end
def status_text
case fields["status"]
when "pending_nightly"
"will be ordered soon..."
when "fulfilled"
"ordered!"
else
super
end
end
end
class HSRawPendingAGHShipment < HighSeasShipment
def type_text
"Pending warehouse shipment"
end
def status_text
case fields["status"]
when "pending_nightly"
"will be sent to the warehouse with the next batch!"
else
super
end
end
def status_icon
return '<i class="fa-solid fa-boxes-stacked"></i>' if fields['status'] == 'pending_nightly'
super
end
end
SHIPMENT_TYPES = [WarehouseShipment, HighSeasShipment].freeze

13
app/signage.rb Normal file
View file

@ -0,0 +1,13 @@
require 'openssl'
def sign(text)
signing_secret = ENV['SIGNING_SECRET']
raise 'SIGNING_SECRET is not set' if signing_secret.nil?
hmac = OpenSSL::HMAC.digest('SHA256', signing_secret, text)
hmac.unpack1('H*')
end
def sig_checks_out?(email, sig)
sign(email) == sig
end

29
app/views/_shipment.erb Normal file
View file

@ -0,0 +1,29 @@
<div class="card">
<h4 style="display: inline-block"><%= shipment.icon %> <%= shipment.title_text %></h4> <span style="float: right"><%= shipment.status_icon %></span>
<hr/>
<i><%= shipment.type_text %></i><br/>
<%= shipment.status_text %>
<%= shipment["WarehouseService"] %>
<% if shipment.tracking_number %>
<br/>
<% if shipment.tracking_link %>
<%= external_link(shipment.tracking_number, shipment.tracking_link) %>
<% else %>
<%= shipment.tracking_number %>
<% end %>
<% end %>
<% if @show_ids %>
<br/>
Airtable: <%= external_link(shipment.source_id, shipment.source_url) %>
<% end %>
<% if shipment.description.is_a? Array %>
<br/><b>Contents:</b>
<ul>
<% shipment.description.each do |item| %>
<li><%= item %></li>
<% end %>
</ul>
<% end %>
<br/><span>Created on <%= shipment.date[..9] %></span>
</div>

View file

@ -0,0 +1,6 @@
<div class="container">
<div class="card" style="background: var(--green); color: var(--white) !important">
<h1>check your email!</h1>
it'll be coming from dinobox@hackclub.com :-)
</div>
</div>

12
app/views/index.erb Normal file
View file

@ -0,0 +1,12 @@
<div class="container">
<div class="card sunken">
<h1>what's <%= @internal ? "their" : "your" %> email?</h1>
<form action="<%= @internal ? '/dyn/internal' : '/dyn/send_mail' %>" method="post">
<input type="email" placeholder="orpheus@hackclub.com" id="email" name="email" style="margin-top: var(--spacing-3)" required="required"> <br/>
<input class="button" type="submit" style="margin-top: var(--spacing-2);" value="go!">
</form>
</div>
<% if @internal %>
<a href="/set_internal_key">set internal key?</a>
<% end %>
</div>

37
app/views/layout.erb Normal file
View file

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html>
<head>
<title>Shipment Viewer <%= @title && " #{@title}" %></title>
<link rel="stylesheet" href="/hc.css">
<link rel="stylesheet" href="/style.css">
<script src="https://kit.fontawesome.com/384fa9fca2.js" crossorigin="anonymous"></script>
<%= yield_content :head %>
<script>
// The poor man's jQuery
window.$ = (query, el=document)=>{
return el.querySelector(query);
};
window.$all = (query, el=document)=>{
return [...el.querySelectorAll(query)];
};
</script>
</head>
<body>
<nav class="navbar fixed-top bg-light">
<a class="banner" target="_blank" href="https://hackclub.com/">
<img src="https://assets.hackclub.com/flag-orpheus-top.svg" />
</a>
<a href='<%= @internal ? '/internal' : '/' %>' class="navbar-brand">Shipment Viewer</a>
</nav>
<div style="margin-top: 64px">
<% if @error %>
<div class="container">
<div class="card" style="background: var(--orange); margin-bottom: var(--spacing-2); color: var(--white)">
<b>error:</b> <%= @error %>
</div>
</div>
<% end %>
<%= yield %>
</div>
</body>
</html>

6
app/views/notfound.erb Normal file
View file

@ -0,0 +1,6 @@
<div class="container">
<div class="card" style="background: var(--red); color: var(--white) !important">
<h1>404 not found....</h1>
this is so sad...
</div>
</div>

View file

@ -0,0 +1,10 @@
<div class="container">
<div class="card sunken">
<h1>here be dragons :-P</h1>
<form action="javascript:void(0);" onsubmit="document.cookie = `internal_key=${$('#key').value}`; alert('set!'); return false">
<input type="password" placeholder=":3" id="key" name="key" style="margin-top: var(--spacing-3)"> <br/>
<input class="button" type="submit" style="margin-top: var(--spacing-2);" value="save">
</form>
</div>
<a href="/internal">to the tool....</a>
</div>

29
app/views/shipments.erb Normal file
View file

@ -0,0 +1,29 @@
<% content_for :head do %>
<script src="https://cdn.jsdelivr.net/npm/macy@2.5.1"></script>
<% end %>
<div class="container">
<% if @shipments.empty? %>
<img src="https://cloud-m5nxd8r27-hack-club-bot.vercel.app/0cleanshot_2025-01-02_at_14.18.09_2x.png" alt="no shipments?">
<% else %>
<div class="shipments" id="shipments-container">
<%# for each shipment, render the partial _shipment.erb: %>
<% @shipments.each do |shipment, i| %>
<%= erb :_shipment, locals: { shipment: } %>
<% end %>
</div>
<% end %>
</div>
<script>
var macy = Macy({
container: '#shipments-container',
trueOrder: true,
waitForImages: false,
margin: 20,
columns: 3,
breakAt: {
940: 2,
700: 1,
}
});
</script>

View file

@ -1,8 +0,0 @@
import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel/serverless';
// https://astro.build/config
export default defineConfig({
output: 'hybrid',
adapter: vercel(),
});

2
config.ru Normal file
View file

@ -0,0 +1,2 @@
require './app/main'
run ShipmentViewer

6295
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,18 +0,0 @@
{
"name": "shipment-viewer",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/vercel": "^7.8.0",
"airtable": "^0.12.2",
"astro": "^4.15.3",
"loops": "^3.2.0"
}
}

View file

@ -1,39 +0,0 @@
---
/* you might be wondering:
* "nora, why is it like this? why are you like this?"
*
* ...this is a 1-to-1 port of a lovingly handcrafted Jinja2 template :3
*/
const {shipment, show_ids} = Astro.props;
---
<div class=`card border-${shipment.status ? "success" : "info"} mb-3`>
<div class="card-header d-flex align-items-center justify-content-between">added
on {shipment.fields['Date Requested']} <span> {shipment.status ?
<abbr title=`doesn't necessarily mean it's been shipped,\nbut it was marked as dispatched on ${shipment.fields['Date Fulfilled']}`>fulfilled</abbr> : 'pending...'}</span>
</div>
<div class="card-body">
<h4 class="card-title">{shipment.fields['Request Type'] ? shipment.fields['Request Type'].join(', ') : "¯\\_(ツ)_/¯"}</h4>
{shipment.status ?
(
<div>
{shipment.fields['WarehouseService'] &&
<p><i>via {shipment.fields['WarehouseService']}</i></p>}
<span>contents:</span>
<ul>
{shipment.contents.map((item) =>
<li>{item}</li>)
|| "¯\\_(ツ)_/¯"}
</ul>
{shipment.fields['WarehouseTracking Number'] ?
(<p>tracking #: <a
href={shipment.fields['WarehouseTracking URL'] || `https://parcelsapp.com/en/tracking/${shipment.fields['WarehouseTracking Number']}`}
target="_blank">{shipment.fields['WarehouseTracking Number']}</a></p>
) : (<p>no tracking on this one...</p>)}
</div>
) : (<p>hasn't been processed yet...</p>)}
{show_ids && <p>Airtable: <a href={shipment.url} target="_blank">{shipment.id}</a></p>}
</div>
</div>

1
src/env.d.ts vendored
View file

@ -1 +0,0 @@
/// <reference path="../.astro/types.d.ts" />

View file

@ -1,25 +0,0 @@
---
import '../styles/bootstrap.min.css';
import '../styles/site.css';
---
<html>
<head>
<title>Hack Club Shipment Viewer</title>
<link rel="stylesheet" href="https://css.hackclub.com/fonts.css"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta charset="UTF-8">
</head>
<body>
<nav class="navbar fixed-top bg-light">
<a class="banner" target="_blank" href="https://hackclub.com/">
<img src="https://assets.hackclub.com/flag-orpheus-top.svg" />
</a>
<a class="navbar-brand">Shipment Viewer</a>
</nav>
<div class="container mt-5">
<br/>
<slot/>
</div>
</body>
</html>

View file

@ -1,19 +0,0 @@
---
import Layout from "../layouts/Layout.astro";
---
<Layout>
<div class="col">
<div class="row">
<div class="page-header">
<h1>check your inbox!</h1>
</div>
</div>
<div class="row">
<div>
<p>we've sent a magic link to verify it's you.</p>
<p>didn't arrive? wait a little while, check your spam folder, etc. etc. etc.</p>
</div>
</div>
</div>
</Layout>

View file

@ -1,48 +0,0 @@
---
import Layout from "../layouts/Layout.astro";
---
<script>
import '../scripts/jquery.js'
const searchParams = new URLSearchParams(window.location.search);
if (searchParams.get('tryAgain')) {
$("#flash").insertAdjacentHTML("beforeend", `<div class="alert alert-info m-auto d-inline-block">
<strong>sorry, we couldn't find any shipments for that email...</strong> <a>is there another one they might be tied to?</a>
</div>`)
$("#submit").innerText = "try another?"
}
if (searchParams.get('error')) {
$("#flash").insertAdjacentHTML("beforeend", `<div class="alert alert-danger m-auto d-inline-block">
<strong>something went wrong:</strong> <a>${searchParams.get('error')}</a>
</div>`)
}
if (searchParams.get('internal')) {
$("#submit").innerText = "😎"
$("#header").innerText = $("#header").innerText.replace("your", "their")
$("#internal").value = searchParams.get('internal')
}
</script>
<Layout>
<div class="col">
<div class="row">
<div id="flash" class="mb-2"/>
</div>
<div class="row">
<h1 id="header">hey, what's your email?</h1>
</div>
<div class="row">
<form action="/api/email" method="post">
<div>
<input type="email" name=email" class="form-control" placeholder="dinobox@hackclub.com" required>
<input type="hidden" name="internal" id="internal"/>
</div>
<small id="emailSubtitle" class="form-text text-muted">(this is the email we'll look up in the shipments database)</small>
<div>
<button type="submit" class="btn btn-success" id="submit">go!</button>
</div>
</form>
</div>
</div>
</Layout>

View file

@ -1,57 +0,0 @@
---
export const prerender = false
import Layout from "../layouts/Layout.astro";
import Shipment from "../components/Shipment.astro"
import {redirect_error,sign,redirect} from "../../util";
import Airtable from 'airtable';
const {searchParams} = new URL(`http://example.com/${Astro.request.url}`)
const email = searchParams.get("email")
const signature = searchParams.get("signature")
const show_ids = !!searchParams.get("show_ids")
console.log(show_ids)
if (!email) return redirect_error("missing email..?")
if (!signature) return redirect_error("missing signature...? weird.")
if (signature !== await sign(email)) return redirect_error("invalid signature :-/")
let shipments = []
// let skus = {}
try {
const base = new Airtable({ apiKey: process.env.AIRTABLE_API_KEY }).base(process.env.MSR_BASE);
const tMSR = base(process.env.MSR_TABLE)
shipments = await tMSR.select({filterByFormula: `{Email} = "${email}"`, sort: [{field: 'Date Requested', direction: 'desc'}]}).all()
} catch (e) {
console.error(email)
console.error(e)
return redirect_error(`error fetching records from Airtable :-(<br/>request ID: ${Astro.request.headers.get('x-vercel-id')}`)
}
if(shipments.length === 0){
console.error("this should not be possible :3")
return redirect("/?tryAgain=yeah")
}
let columns = [[],[],[]]
for (let i = 0; i < shipments.length; i++) {
shipments[i].status = !!shipments[i].fields["Date Fulfilled"]
shipments[i].contents = shipments[i].fields["WarehouseItems Shipped JSON"] ?
JSON.parse(shipments[i].fields["WarehouseItems Shipped JSON"])
.map((item)=> `${item.quantity}x ${item.name}`)
: [shipments[i].fields["Custom Instructions"]]
shipments[i].url = `https://airtable.com/${process.env.MSR_BASE}/${process.env.MSR_TABLE}/${shipments[i].id}?blocks=hide`
}
---
<Layout>
<div class="container">
<div class="row">
{shipments.map((shipment) => (
<div class="col-12 col-sm-6 col-md-4 col-lg-4">
<Shipment shipment={shipment} show_ids={show_ids}/>
</div>
))}
</div>
</div>
</Layout>

View file

@ -1,7 +0,0 @@
window.$ = (query, el=document)=>{
"The poor man's jQuery"
return el.querySelector(query);
};
window.$all = (query, el=document)=>{
return [...el.querySelectorAll(query)];
};

File diff suppressed because one or more lines are too long

View file

@ -1,3 +0,0 @@
{
"extends": "astro/tsconfigs/base"
}

39
util.js
View file

@ -1,39 +0,0 @@
// it is what it is
export const EMAIL_REGEX = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
export async function sign(text) {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(process.env.SIGNING_SECRET), {
name: "HMAC",
hash: {name: "SHA-256"}
},
false, ["sign"]);
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(text));
return Array.from(new Uint8Array(signature)).map(b => b.toString(16).padStart(2, '0')).join('');
}
export function redirect(url, additional_headers = {}) {
return new Response(null, {
status: 301,
headers: {
Location: url,
...additional_headers
}
});
}
export function redirect_error(error_text) {
return redirect(`/?error=${encodeURIComponent(error_text)}`)
}
export async function gen_shipments_url(email, show_ids) {
let params = {
email,
signature: await sign(email),
}
if (show_ids) params.show_ids = "yep"
return `${process.env.BASE_URL}/shipments?${new URLSearchParams(params).toString()}`
}

6
vercel-build.sh Executable file
View file

@ -0,0 +1,6 @@
#this is kinda bad
mkdir -p build
cp -r app/public/* build
bundle config set --local without serve
bundle install
APP_ENV=development ruby app/build.rb

37
vercel.json Normal file
View file

@ -0,0 +1,37 @@
{
"builds": [
{
"src": "config.ru",
"use": "@vercel/ruby"
},
{
"src": "vercel-build.sh",
"use": "@vercel/static-build",
"config": {"distDir": "build"}
}
],
"rewrites": [
{ "source": "/config.ru", "destination": "404" },
{ "source": "/dyn/:path*", "destination": "config.ru" },
{ "source": "/api/presign", "destination": "config.ru" },
{ "source": "/(.*)", "destination": "404", "statusCode": 404 }
],
"cleanUrls": true,
"redirects": [
{
"source": "/shipments",
"has": [
{
"type": "query",
"key": "email"
},
{
"type": "query",
"key": "signature"
}
],
"destination": "/dyn/shipments/:email?signature=:signature",
"permanent": true
}
]
}