mirror of
https://github.com/System-End/shipment-viewer.git
synced 2026-04-19 14:17:02 +00:00
new shipment viewer
This commit is contained in:
parent
ab026ab3ae
commit
0d1d83587d
39 changed files with 1403 additions and 6653 deletions
7
.env.test
Normal file
7
.env.test
Normal 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
1
.gitignore
vendored
|
|
@ -23,3 +23,4 @@ pnpm-debug.log*
|
|||
# jetbrains setting folder
|
||||
.idea/
|
||||
.vercel
|
||||
build
|
||||
15
Gemfile
Normal file
15
Gemfile
Normal 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
70
Gemfile.lock
Normal 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
|
||||
26
README.md
26
README.md
|
|
@ -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.
|
||||
The code is... written, I'll give it that much.
|
||||
Look upon the [Marketing - Shipment Request](https://airtable.com/appK53aN0fz3sgJ4w/) base for inspiration.
|
||||
this one kinda maybe looks more like actual usable maintainable software
|
||||
|
||||
## Developing
|
||||
Fill in .env, `vercel dev`, pray.
|
||||
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
|
||||
## 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>`
|
||||
59
api/email.js
59
api/email.js
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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
17
app/awawawa.rb
Normal 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
14
app/build.rb
Normal 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
20
app/loops.rb
Normal 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
110
app/main.rb
Normal 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
|
||||
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
583
app/public/hc.css
Normal file
583
app/public/hc.css
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
height: 80px;
|
||||
|
|
@ -41,4 +57,23 @@ a:focus,
|
|||
a:hover {
|
||||
text-decoration-style: wavy;
|
||||
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
326
app/shipment_types.rb
Normal 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["Warehouse–Tracking URL"]
|
||||
end
|
||||
|
||||
def tracking_number
|
||||
fields["Warehouse–Tracking Number"] unless fields["Warehouse–Tracking 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["Warehouse–Service"]&.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["Warehouse–Items Shipped JSON"] && JSON.parse(fields["Warehouse–Items 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
13
app/signage.rb
Normal 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
29
app/views/_shipment.erb
Normal 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["Warehouse–Service"] %>
|
||||
<% 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>
|
||||
6
app/views/check_ur_email.erb
Normal file
6
app/views/check_ur_email.erb
Normal 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
12
app/views/index.erb
Normal 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
37
app/views/layout.erb
Normal 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
6
app/views/notfound.erb
Normal 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>
|
||||
10
app/views/set_internal_key.erb
Normal file
10
app/views/set_internal_key.erb
Normal 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
29
app/views/shipments.erb
Normal 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>
|
||||
|
|
@ -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
2
config.ru
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
require './app/main'
|
||||
run ShipmentViewer
|
||||
6295
package-lock.json
generated
6295
package-lock.json
generated
File diff suppressed because it is too large
Load diff
18
package.json
18
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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['Warehouse–Service'] &&
|
||||
<p><i>via {shipment.fields['Warehouse–Service']}</i></p>}
|
||||
<span>contents:</span>
|
||||
<ul>
|
||||
{shipment.contents.map((item) =>
|
||||
<li>{item}</li>)
|
||||
|| "¯\\_(ツ)_/¯"}
|
||||
</ul>
|
||||
|
||||
{shipment.fields['Warehouse–Tracking Number'] ?
|
||||
(<p>tracking #: <a
|
||||
href={shipment.fields['Warehouse–Tracking URL'] || `https://parcelsapp.com/en/tracking/${shipment.fields['Warehouse–Tracking Number']}`}
|
||||
target="_blank">{shipment.fields['Warehouse–Tracking 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
1
src/env.d.ts
vendored
|
|
@ -1 +0,0 @@
|
|||
/// <reference path="../.astro/types.d.ts" />
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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["Warehouse–Items Shipped JSON"] ?
|
||||
JSON.parse(shipments[i].fields["Warehouse–Items 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>
|
||||
7
src/scripts/jquery.js
vendored
7
src/scripts/jquery.js
vendored
|
|
@ -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)];
|
||||
};
|
||||
1
src/styles/bootstrap.min.css
vendored
1
src/styles/bootstrap.min.css
vendored
File diff suppressed because one or more lines are too long
|
|
@ -1,3 +0,0 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/base"
|
||||
}
|
||||
39
util.js
39
util.js
|
|
@ -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
6
vercel-build.sh
Executable 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
37
vercel.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue