├── .editorconfig ├── .gitignore ├── .nvmrc ├── LICENSE ├── README.md ├── babel.config.json ├── bin ├── compile.js └── server.js ├── build ├── webpack-compiler.js └── webpack.config.js ├── config ├── environments.js └── index.js ├── other └── favicon.afdesign ├── package.json ├── src ├── components │ ├── Button.js │ ├── Cell.js │ ├── Header.js │ ├── Input.js │ ├── Link.js │ ├── Modal.js │ ├── NewAddressModal.js │ ├── Spacer.js │ ├── Spinner.js │ ├── Step.js │ └── index.js ├── constants.js ├── containers │ ├── AppContainer.js │ └── StepRouter.js ├── index.html ├── index.js ├── layouts │ └── StepLayout.js ├── lib │ ├── render-back.js │ └── render-front.js ├── static │ ├── favicon.png │ ├── reset.css │ └── robots.txt ├── steps │ ├── FromAddressStep.js │ ├── ImageStep.js │ ├── LobStep.js │ ├── MessageStep.js │ ├── PreviewStep.js │ ├── SendStep.js │ ├── SizeStep.js │ ├── ToAddressStep.js │ ├── WelcomeStep.js │ └── index.js ├── store │ ├── create-store.js │ ├── postcard-test-data.js │ ├── postcard.js │ └── reducers.js ├── styles │ ├── constants.js │ ├── global.js │ └── reset-css.js └── util.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_STORE 2 | *.log 3 | 4 | node_modules 5 | 6 | dist 7 | coverage 8 | 9 | .idea/ 10 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 23 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 L33T KR3W 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [post][post-href] 2 | 3 | ## inspiration 4 | 5 | `post` is an idea I had when I was home in Colorado. I was spending time with my grandma and it made me really sad that I couldn't communicate with her easily while I'm away. She's hard of hearing and doesn't own a computer. I realized it would be great if I could send her some pictures and letters every once in a while: postcards! 6 | 7 | The whole website is powered by [Lob][lob-href], which is an awesome printing API. It's pretty cheap too: just $0.70 for a 4"x6" postcard and $1.50 for a colossal 6"x11" postcard! Since `post` uses the Lob API directly, you pay the Lob price and nothing more. I'm not out to make money with this project. 8 | 9 | 10 | ## technical summary 11 | 12 | Some code things used in `post`: 13 | 14 | * React + Redux 15 | * [csjs][csjs-href] 16 | * Babel 17 | * Netlify for hosting 18 | 19 | 20 | [post-href]: https://post.scotthardy.me 21 | [lob-href]: https://lob.com 22 | [csjs-href]: https://github.com/rtsao/csjs 23 | -------------------------------------------------------------------------------- /babel.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"] 3 | } 4 | -------------------------------------------------------------------------------- /bin/compile.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra'); 2 | const debug = require('debug')('app:bin:compile'); 3 | const webpackCompiler = require('../build/webpack-compiler'); 4 | const webpackConfig = require('../build/webpack.config'); 5 | const config = require('../config'); 6 | 7 | const paths = config.utils_paths; 8 | 9 | const compile = () => { 10 | debug('Starting compiler.'); 11 | return Promise.resolve() 12 | .then(() => webpackCompiler(webpackConfig)) 13 | .then(stats => { 14 | if (stats.warnings.length && config.compiler_fail_on_warning) { 15 | throw new Error('Config set to fail on warning, exiting with status code "1".'); 16 | } 17 | debug('Copying static assets to dist folder.'); 18 | fs.copySync(paths.client('static'), paths.dist()); 19 | }) 20 | .then(() => { 21 | debug('Compilation completed successfully.'); 22 | }) 23 | .catch((err) => { 24 | debug('Compiler encountered an error.', err); 25 | process.exit(1); 26 | }); 27 | }; 28 | 29 | compile(); 30 | -------------------------------------------------------------------------------- /bin/server.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const webpack = require("webpack"); 3 | const WebpackDevMiddleware = require("webpack-dev-middleware"); 4 | 5 | const port = process.env.PORT || 3000; 6 | const webpackConfig = require("../build/webpack.config"); 7 | const compiler = webpack(webpackConfig); 8 | 9 | const app = express(); 10 | app.use(WebpackDevMiddleware(compiler)); 11 | app.use(express.static("./dist")); 12 | 13 | app.listen(port); 14 | console.log(`Server is now running at http://localhost:${port}.`); 15 | -------------------------------------------------------------------------------- /build/webpack-compiler.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const debug = require('debug')('app:build:webpack-compiler'); 3 | const config = require('../config'); 4 | 5 | function webpackCompiler (webpackConfig, statsFormat) { 6 | statsFormat = statsFormat || config.compiler_stats; 7 | 8 | return new Promise((resolve, reject) => { 9 | const compiler = webpack(webpackConfig); 10 | 11 | compiler.run((err, stats) => { 12 | if (err) { 13 | debug('Webpack compiler encountered a fatal error.', err); 14 | return reject(err); 15 | } 16 | 17 | const jsonStats = stats.toJson(); 18 | debug('Webpack compile completed.'); 19 | debug(stats.toString(statsFormat)); 20 | 21 | if (jsonStats.errors.length > 0) { 22 | debug('Webpack compiler encountered errors.'); 23 | debug(jsonStats.errors.join('\n')); 24 | return reject(new Error('Webpack compiler encountered errors')); 25 | } else if (jsonStats.warnings.length > 0) { 26 | debug('Webpack compiler encountered warnings.'); 27 | debug(jsonStats.warnings.join('\n')); 28 | } else { 29 | debug('No errors or warnings encountered.'); 30 | } 31 | resolve(jsonStats); 32 | }); 33 | }); 34 | } 35 | 36 | module.exports = webpackCompiler; 37 | -------------------------------------------------------------------------------- /build/webpack.config.js: -------------------------------------------------------------------------------- 1 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 2 | 3 | module.exports = { 4 | entry: "./src/index.js", 5 | mode: process.env.NODE_ENV || "development", 6 | watch: true, 7 | devtool: "eval-cheap-module-source-map", 8 | module: { 9 | rules: [ 10 | { 11 | test: /\.m?js$/, 12 | exclude: /node_modules/, 13 | use: { 14 | loader: "babel-loader", 15 | options: { 16 | presets: ["@babel/preset-env", "@babel/preset-react"], 17 | }, 18 | }, 19 | }, 20 | ], 21 | }, 22 | plugins: [ 23 | new HtmlWebpackPlugin({ 24 | template: "./src/index.html", 25 | }), 26 | ], 27 | }; 28 | -------------------------------------------------------------------------------- /config/environments.js: -------------------------------------------------------------------------------- 1 | // Here is where you can define configuration overrides based on the execution environment. 2 | // Supply a key to the default export matching the NODE_ENV that you wish to target, and 3 | // the base configuration will apply your overrides before exporting itself. 4 | module.exports = { 5 | // ====================================================== 6 | // Overrides when NODE_ENV === 'development' 7 | // ====================================================== 8 | // NOTE: In development, we use an explicit public path when the assets 9 | // are served webpack by to fix this issue: 10 | // http://stackoverflow.com/questions/34133808/webpack-ots-parsing-error-loading-fonts/34133809#34133809 11 | development : (config) => ({ 12 | compiler_public_path : `http://${config.server_host}:${config.server_port}/` 13 | }), 14 | 15 | // ====================================================== 16 | // Overrides when NODE_ENV === 'production' 17 | // ====================================================== 18 | production : (config) => ({ 19 | compiler_public_path : '/', 20 | compiler_fail_on_warning : false, 21 | compiler_hash_type : 'chunkhash', 22 | compiler_devtool : null, 23 | compiler_stats : { 24 | chunks : true, 25 | chunkModules : true, 26 | colors : true 27 | } 28 | }) 29 | }; 30 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const debug = require('debug')('app:config'); 3 | const argv = require('yargs').argv; 4 | 5 | debug('Creating default configuration.'); 6 | // ======================================================== 7 | // Default Configuration 8 | // ======================================================== 9 | const config = { 10 | env : process.env.NODE_ENV || 'development', 11 | 12 | // ---------------------------------- 13 | // Project Structure 14 | // ---------------------------------- 15 | path_base : path.resolve(__dirname, '..'), 16 | dir_client : 'src', 17 | dir_dist : 'dist', 18 | dir_server : 'server', 19 | 20 | // ---------------------------------- 21 | // Server Configuration 22 | // ---------------------------------- 23 | server_host : 'localhost', 24 | server_port : process.env.PORT || 3000, 25 | 26 | // ---------------------------------- 27 | // Compiler Configuration 28 | // ---------------------------------- 29 | compiler_babel : { 30 | cacheDirectory : true, 31 | plugins : ['transform-runtime'], 32 | presets : ['es2015', 'react', 'stage-0'] 33 | }, 34 | compiler_devtool : 'source-map', 35 | compiler_hash_type : 'hash', 36 | compiler_fail_on_warning : false, 37 | compiler_quiet : false, 38 | compiler_public_path : '/', 39 | compiler_stats : { 40 | chunks : false, 41 | chunkModules : false, 42 | colors : true 43 | }, 44 | compiler_vendors : [ 45 | 'react', 46 | 'react-redux', 47 | 'redux' 48 | ], 49 | 50 | // ---------------------------------- 51 | // Test Configuration 52 | // ---------------------------------- 53 | coverage_reporters : [ 54 | { type : 'text-summary' }, 55 | { type : 'lcov', dir : 'coverage' } 56 | ] 57 | }; 58 | 59 | /************************************************ 60 | ------------------------------------------------- 61 | 62 | All Internal Configuration Below 63 | Edit at Your Own Risk 64 | 65 | ------------------------------------------------- 66 | ************************************************/ 67 | 68 | // ------------------------------------ 69 | // Environment 70 | // ------------------------------------ 71 | config.globals = { 72 | 'process.env' : { 73 | 'NODE_ENV' : JSON.stringify(config.env) 74 | }, 75 | 'NODE_ENV' : config.env, 76 | '__DEV__' : config.env === 'development', 77 | '__PROD__' : config.env === 'production', 78 | '__TEST__' : config.env === 'test', 79 | '__COVERAGE__' : !argv.watch && config.env === 'test', 80 | '__BASENAME__' : JSON.stringify(process.env.BASENAME || '') 81 | }; 82 | 83 | // ------------------------------------ 84 | // Validate Vendor Dependencies 85 | // ------------------------------------ 86 | const pkg = require('../package.json'); 87 | 88 | config.compiler_vendors = config.compiler_vendors 89 | .filter((dep) => { 90 | if (pkg.dependencies[dep]) return true; 91 | 92 | debug( 93 | `Package "${dep}" was not found as an npm dependency in package.json; ` + 94 | `it won't be included in the webpack vendor bundle. 95 | Consider removing it from \`compiler_vendors\` in ~/config/index.js` 96 | ); 97 | }); 98 | 99 | // ------------------------------------ 100 | // Utilities 101 | // ------------------------------------ 102 | function base () { 103 | const args = [config.path_base].concat([].slice.call(arguments)); 104 | return path.resolve.apply(path, args); 105 | } 106 | 107 | config.utils_paths = { 108 | base : base, 109 | client : base.bind(null, config.dir_client), 110 | dist : base.bind(null, config.dir_dist) 111 | }; 112 | 113 | // ======================================================== 114 | // Environment Configuration 115 | // ======================================================== 116 | debug(`Looking for environment overrides for NODE_ENV "${config.env}".`); 117 | const environments = require('./environments'); 118 | const overrides = environments[config.env]; 119 | if (overrides) { 120 | debug('Found overrides, applying to default configuration.'); 121 | Object.assign(config, overrides(config)); 122 | } else { 123 | debug('No environment overrides found, defaults will be used.'); 124 | } 125 | 126 | module.exports = config; 127 | -------------------------------------------------------------------------------- /other/favicon.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scott113341/post/5a6c2e14fa31eba69fdee310ea5d06359e843318/other/favicon.afdesign -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "post", 3 | "version": "3.0.0", 4 | "description": "Self-contained website to send postcards via the Lob API.", 5 | "author": "Scott Hardy", 6 | "repository": "git@github.com:scott113341/post.git", 7 | "homepage": "https://github.com/scott113341/post", 8 | "bugs": "https://github.com/scott113341/post/issues", 9 | "private": true, 10 | "scripts": { 11 | "build": "better-npm-run build", 12 | "dev": "better-npm-run dev" 13 | }, 14 | "betterScripts": { 15 | "build": { 16 | "command": "rimraf dist && node bin/compile", 17 | "env": { 18 | "NODE_ENV": "production", 19 | "DEBUG": "app:*" 20 | } 21 | }, 22 | "dev": { 23 | "command": "node bin/server.js --ignore dist --ignore coverage --ignore src", 24 | "env": { 25 | "NODE_ENV": "development", 26 | "DEBUG": "app:*", 27 | "PORT": 3002 28 | } 29 | } 30 | }, 31 | "license": "MIT", 32 | "dependencies": { 33 | "@babel/core": "^7.26.0", 34 | "@babel/preset-env": "^7.26.0", 35 | "@babel/preset-react": "^7.25.9", 36 | "babel-loader": "^9.2.1", 37 | "better-npm-run": "0.0.11", 38 | "canvg": "^4.0.2", 39 | "classnames": "2.2.5", 40 | "csjs-inject": "1.0.1", 41 | "debug": "2.6.9", 42 | "express": "^4.21.1", 43 | "fs-extra": "0.30.0", 44 | "html-webpack-plugin": "^5.6.3", 45 | "lodash": "^4.17.21", 46 | "prettier": "^3.3.3", 47 | "react": "15.3.2", 48 | "react-dom": "15.3.2", 49 | "react-frame-component": "0.6.6", 50 | "react-redux": "4.4.5", 51 | "redux": "3.6.0", 52 | "redux-thunk": "2.1.0", 53 | "rimraf": "2.5.4", 54 | "webpack": "^5.96.1", 55 | "webpack-dev-middleware": "^7.4.2", 56 | "yargs": "6.3.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/components/Button.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import csjs from "csjs-inject"; 3 | 4 | import { buttonBackgroundColor } from "../styles/constants"; 5 | 6 | const Button = (props) => 60 | 61 | 62 | ); 63 | } 64 | 65 | handleCancelClick = () => { 66 | this.props.onCancel(); 67 | }; 68 | 69 | handleSaveClick = () => { 70 | this.props.onSave({ 71 | addressName: this.refs.addressName.value, 72 | addressLine1: this.refs.addressLine1.value, 73 | addressLine2: this.refs.addressLine2.value, 74 | addressCountry: "US", 75 | addressCity: this.refs.addressCity.value, 76 | addressState: this.refs.addressState.value, 77 | addressZip: this.refs.addressZip.value, 78 | }); 79 | }; 80 | } 81 | 82 | export const styles = csjs` 83 | .input { 84 | width: 100%; 85 | } 86 | `; 87 | -------------------------------------------------------------------------------- /src/components/Spacer.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import csjs from "csjs-inject"; 3 | 4 | export default class Spacer extends React.Component { 5 | static propTypes = { 6 | height: React.PropTypes.string, 7 | }; 8 | 9 | render() { 10 | const { height = "30px", ...rest } = this.props; 11 | const style = { height }; 12 | 13 | return
; 14 | } 15 | } 16 | 17 | const styles = csjs` 18 | .spacer { 19 | background: none; 20 | } 21 | `; 22 | -------------------------------------------------------------------------------- /src/components/Spinner.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import csjs from "csjs-inject"; 3 | 4 | export default class Spinner extends React.Component { 5 | render() { 6 | return ( 7 |
8 |
9 |
10 |
11 |
12 |
13 | ); 14 | } 15 | } 16 | 17 | const styles = csjs` 18 | .spinner { 19 | margin: 0 auto; 20 | width: 70px; 21 | text-align: center; 22 | } 23 | 24 | .bar { 25 | width: 9px; 26 | height: 18px; 27 | margin: 0 3px; 28 | background-color: #333; 29 | display: inline-block; 30 | animation: bar 1.6s infinite ease-in-out both; 31 | } 32 | 33 | .bar1 extends .bar { 34 | animation-delay: -0.6s; 35 | } 36 | 37 | .bar2 extends .bar { 38 | animation-delay: -0.4s; 39 | } 40 | 41 | .bar3 extends .bar { 42 | animation-delay: -0.2s; 43 | } 44 | 45 | .bar4 extends .bar { 46 | animation-delay: 0s; 47 | } 48 | 49 | @keyframes bar { 50 | 0%, 80%, 100% { 51 | transform: scale(0); 52 | } 53 | 40% { 54 | transform: scale(1.0); 55 | } 56 | } 57 | `; 58 | -------------------------------------------------------------------------------- /src/components/Step.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import csjs from "csjs-inject"; 3 | 4 | export default class Step extends React.Component { 5 | static propTypes = { 6 | title: React.PropTypes.string.isRequired, 7 | children: React.PropTypes.node.isRequired, 8 | }; 9 | 10 | render() { 11 | const { title, children } = this.props; 12 | 13 | return ( 14 |
15 |

{title}

16 | {children} 17 |
18 | ); 19 | } 20 | } 21 | 22 | const styles = csjs` 23 | .container { 24 | text-align: center; 25 | } 26 | 27 | .title { 28 | margin: 10px 0; 29 | font-size: 22px; 30 | font-weight: 300; 31 | } 32 | `; 33 | -------------------------------------------------------------------------------- /src/components/index.js: -------------------------------------------------------------------------------- 1 | export { default as Button } from "./Button.js"; 2 | export { default as Cell } from "./Cell.js"; 3 | export { default as Header } from "./Header.js"; 4 | export { default as Input } from "./Input.js"; 5 | export { default as Link } from "./Link.js"; 6 | export { default as Modal } from "./Modal.js"; 7 | export { default as NewAddressModal } from "./NewAddressModal.js"; 8 | export { default as Spacer } from "./Spacer.js"; 9 | export { default as Spinner } from "./Spinner.js"; 10 | export { default as Step } from "./Step.js"; 11 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const LOB_API_KEY = "LOB_API_KEY"; 2 | 3 | export const BLEED = 0.125; 4 | export const TEXT_BLEED = 0.125; 5 | 6 | export const POSTAGE_WIDTH = 0.78; 7 | export const POSTAGE_HEIGHT = 0.639; 8 | export const POSTAGE_OFFSET_TOP = 0.15; 9 | export const POSTAGE_OFFSET_RIGHT = 0.15; 10 | 11 | export const ADDRESS_HEIGHT = 2.375; 12 | export const ADDRESS_PADDING_LEFT = 0.15; 13 | export const ADDRESS_FROM_PADDING_TOP = 0.125; 14 | export const ADDRESS_FROM_FONT_SIZE = 0.12; 15 | export const ADDRESS_TO_PADDING_TOP = 1.2; 16 | export const ADDRESS_TO_FONT_SIZE = 0.14; 17 | export const ADDRESS_FONT = "Times New Roman"; 18 | 19 | function computeAddressLeft({ width, addressWidth }) { 20 | return width - BLEED - 0.15 - addressWidth; 21 | } 22 | 23 | function computeAddressTop({ height }) { 24 | return height - BLEED - 0.125 - ADDRESS_HEIGHT; 25 | } 26 | 27 | function computePostageLeft({ addressLeft, addressWidth }) { 28 | return addressLeft + addressWidth - POSTAGE_WIDTH - POSTAGE_OFFSET_RIGHT; 29 | } 30 | 31 | function computePostageTop({ addressTop }) { 32 | return addressTop + POSTAGE_OFFSET_TOP; 33 | } 34 | 35 | export const POSTCARD_4X6 = (() => { 36 | const width = 6.25; 37 | const height = 4.25; 38 | const addressWidth = 3.2835; 39 | 40 | const addressLeft = computeAddressLeft({ width, addressWidth }); 41 | const addressTop = computeAddressTop({ height }); 42 | const postageLeft = computePostageLeft({ addressLeft, addressWidth }); 43 | const postageTop = computePostageTop({ addressTop }); 44 | 45 | return { 46 | name: "4x6", 47 | display: `4"x6"`, 48 | price: 0.833, 49 | uspsClass: "usps_first_class", 50 | width, 51 | height, 52 | addressWidth, 53 | addressLeft, 54 | addressTop, 55 | postageLeft, 56 | postageTop, 57 | }; 58 | })(); 59 | 60 | export const POSTCARD_6X9 = (() => { 61 | const width = 9.25; 62 | const height = 6.25; 63 | const addressWidth = 4; 64 | 65 | const addressLeft = computeAddressLeft({ width, addressWidth }); 66 | const addressTop = computeAddressTop({ height }); 67 | const postageLeft = computePostageLeft({ addressLeft, addressWidth }); 68 | const postageTop = computePostageTop({ addressTop }); 69 | 70 | return { 71 | name: "6x9", 72 | display: '6"x9"', 73 | price: 0.954, 74 | uspsClass: "usps_first_class", 75 | width, 76 | height, 77 | addressWidth, 78 | addressLeft, 79 | addressTop, 80 | postageLeft, 81 | postageTop, 82 | }; 83 | })(); 84 | 85 | export const POSTCARD_6X11 = (() => { 86 | const width = 11.25; 87 | const height = 6.25; 88 | const addressWidth = 4; 89 | 90 | const addressLeft = computeAddressLeft({ width, addressWidth }); 91 | const addressTop = computeAddressTop({ height }); 92 | const postageLeft = computePostageLeft({ addressLeft, addressWidth }); 93 | const postageTop = computePostageTop({ addressTop }); 94 | 95 | return { 96 | name: "6x11", 97 | display: '6"x11"', 98 | price: 0.993, 99 | uspsClass: "usps_standard", 100 | width, 101 | height, 102 | addressWidth, 103 | addressLeft, 104 | addressTop, 105 | postageLeft, 106 | postageTop, 107 | }; 108 | })(); 109 | -------------------------------------------------------------------------------- /src/containers/AppContainer.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from "react"; 2 | import { Provider } from "react-redux"; 3 | 4 | import StepRouter from "./StepRouter"; 5 | 6 | class AppContainer extends Component { 7 | static propTypes = { 8 | store: PropTypes.object.isRequired, 9 | }; 10 | 11 | shouldComponentUpdate() { 12 | return false; 13 | } 14 | 15 | render() { 16 | const { store } = this.props; 17 | 18 | return ( 19 | 20 |
21 | 22 |
23 |
24 | ); 25 | } 26 | } 27 | 28 | export default AppContainer; 29 | -------------------------------------------------------------------------------- /src/containers/StepRouter.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { connect } from "react-redux"; 3 | 4 | import "../styles/global.js"; 5 | 6 | import * as postcard from "../store/postcard.js"; 7 | import * as steps from "../steps"; 8 | import StepLayout from "../layouts/StepLayout"; 9 | 10 | const STEPS = [ 11 | steps.WelcomeStep, 12 | steps.LobStep, 13 | steps.SizeStep, 14 | steps.ImageStep, 15 | steps.MessageStep, 16 | steps.FromAddressStep, 17 | steps.ToAddressStep, 18 | steps.PreviewStep, 19 | steps.SendStep, 20 | ]; 21 | 22 | export const StepRouter = (props) => { 23 | const { stepIndex } = props.postcard; 24 | const stepComponent = STEPS[stepIndex]; 25 | const stepElement = React.createElement(stepComponent, props); 26 | 27 | return {stepElement}; 28 | }; 29 | 30 | const mapStateToProps = (state) => ({ 31 | postcard: state.postcard, 32 | }); 33 | 34 | const postcardActions = Object.keys(postcard).reduce((a, c) => { 35 | if (typeof postcard[c] === "function") { 36 | return Object.assign(a, { [c]: postcard[c] }); 37 | } else { 38 | return a; 39 | } 40 | }, {}); 41 | 42 | export default connect(mapStateToProps, postcardActions)(StepRouter); 43 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | post 5 | 6 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | 4 | import createStore from "./store/create-store"; 5 | import AppContainer from "./containers/AppContainer"; 6 | 7 | ReactDOM.render( 8 | , 9 | document.getElementById("root"), 10 | ); 11 | -------------------------------------------------------------------------------- /src/layouts/StepLayout.js: -------------------------------------------------------------------------------- 1 | import csjs from "csjs-inject"; 2 | import React from "react"; 3 | 4 | import "../styles/global.js"; 5 | 6 | import { Header } from "../components/index.js"; 7 | 8 | export const StepLayout = (props) => { 9 | return ( 10 |
11 |
12 |
{props.children}
13 |
14 | ); 15 | }; 16 | 17 | export const styles = csjs` 18 | .container { 19 | margin: 0 auto; 20 | padding: 3px 6px 25px; 21 | max-width: 800px; 22 | } 23 | `; 24 | 25 | export default StepLayout; 26 | -------------------------------------------------------------------------------- /src/lib/render-back.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDomServer from "react-dom/server"; 3 | import { Canvg, presets } from "canvg"; 4 | import { 5 | ADDRESS_FONT, 6 | ADDRESS_FROM_FONT_SIZE, 7 | ADDRESS_FROM_PADDING_TOP, 8 | ADDRESS_HEIGHT, 9 | ADDRESS_PADDING_LEFT, 10 | ADDRESS_TO_FONT_SIZE, 11 | ADDRESS_TO_PADDING_TOP, 12 | BLEED, 13 | POSTAGE_HEIGHT, 14 | POSTAGE_WIDTH, 15 | TEXT_BLEED, 16 | } from "../constants"; 17 | 18 | export default async function renderBack({ 19 | size, 20 | message, 21 | isPreview, 22 | fromAddress = null, 23 | toAddress = null, 24 | scale = 300, 25 | }) { 26 | const { width, height } = size; 27 | const d = (v) => (v * scale).toFixed(6); 28 | 29 | // Use Canvg to manually calculate what the postcard lines should 30 | // be, since SVG can't do text wrapping (well, it can if you use 31 | // foreignObject, but we can't use that because we can't render an 32 | // image from the Canvas if it contains a foreignObject... 33 | const lines = await (async () => { 34 | const svg = makeSVG({ 35 | size, 36 | message, 37 | lines: [], 38 | fromAddress, 39 | toAddress, 40 | isPreview: true, 41 | }); 42 | 43 | // Init SVG canvas 44 | const preset = presets.offscreen(); 45 | const canvas = new OffscreenCanvas(d(width), d(height)); 46 | const ctx = canvas.getContext("2d"); 47 | const canvasThing = new Canvg(ctx, svg, preset); 48 | await canvasThing.render(); 49 | 50 | // States: 51 | // - NEW_LINE 52 | // - Word fits => add word 53 | // - Word doesn't fit => add as many characters w/ hyphen 54 | // - No word => done 55 | // - EXISTING_LINE 56 | // - Word fits => add word 57 | // - Word doesn't fit 58 | // - Word fits on next line => new line 59 | // - Word won't fit on next line => add as many characters w/ hyphen 60 | // - Space fits => add space 61 | // - Space doesn't fit => drop space 62 | // - No word => done 63 | // - DONE 64 | // - Add the final line if it has chars 65 | 66 | const lines = []; 67 | let state = "NEW_LINE"; 68 | let currentLine = ""; 69 | let nextLine = ""; 70 | 71 | const currentLineIndex = () => lines.length; 72 | 73 | const maxWidthForLine = (lineIndex) => { 74 | const addressBox = canvasThing.documentElement.children.find( 75 | (e) => e.getAttribute("id")?.value === "addressBox", 76 | ); 77 | const addressBoxBB = addressBox.getBoundingBox(); 78 | 79 | // The "lowest" this line gets on the page (maximum Y coordinate) 80 | const lineMaxY = 81 | BLEED + TEXT_BLEED + message.fontSpacing * (lineIndex + 1); 82 | 83 | if (lineMaxY >= addressBoxBB.y1 - TEXT_BLEED) { 84 | // Line is far enough down postcard to collide with address box 85 | return addressBoxBB.x1 - BLEED - TEXT_BLEED * 1.5; 86 | } else { 87 | // Line is high enough to not collide with address box 88 | return size.width - BLEED * 2 - TEXT_BLEED * 2; 89 | } 90 | }; 91 | 92 | const getTextWidth = (text) => { 93 | return canvasThing.documentElement.children[0].measureTargetText( 94 | ctx, 95 | text, 96 | ); 97 | }; 98 | 99 | const isLineBreak = (chars) => chars === "\n"; 100 | 101 | const charsFit = (chars) => { 102 | const testLine = currentLine + chars; 103 | const width = getTextWidth(testLine); 104 | const maxWidth = maxWidthForLine(currentLineIndex()); 105 | return width <= maxWidth; 106 | }; 107 | 108 | const charsFitEntirelyOnNextLine = (chars) => { 109 | const width = getTextWidth(chars); 110 | const nextLineMaxWidth = maxWidthForLine(currentLineIndex() + 1); 111 | return width <= nextLineMaxWidth; 112 | }; 113 | 114 | const addCharsWithHyphen = (chars) => { 115 | const maxWidth = maxWidthForLine(currentLineIndex()); 116 | let remaining = chars; 117 | 118 | while (true) { 119 | const char = remaining.at(0); 120 | const newTestLine = currentLine + char + "-"; 121 | const newTestWidth = getTextWidth(newTestLine); 122 | 123 | if (newTestWidth > maxWidth) { 124 | currentLine += "-"; 125 | break; 126 | } else { 127 | remaining = remaining.slice(1); 128 | currentLine += char; 129 | } 130 | } 131 | 132 | return remaining; 133 | }; 134 | 135 | const regex = /\S+|\s/g; 136 | const getNextChunk = () => { 137 | const chunk = regex.exec(message.content); 138 | return chunk === null ? chunk : chunk[0]; 139 | }; 140 | 141 | while (true) { 142 | if (state === "NEW_LINE") { 143 | const chunk = nextLine.length ? nextLine : getNextChunk(); 144 | nextLine = ""; 145 | 146 | if (chunk === null) { 147 | state = "DONE"; 148 | } else if (isLineBreak(chunk)) { 149 | lines.push(""); 150 | currentLine = ""; 151 | state = "NEW_LINE"; 152 | } else if (charsFit(chunk)) { 153 | currentLine = chunk; 154 | state = "EXISTING_LINE"; 155 | } else { 156 | nextLine = addCharsWithHyphen(chunk); 157 | lines.push(currentLine); 158 | currentLine = ""; 159 | state = "NEW_LINE"; 160 | } 161 | } else if (state === "EXISTING_LINE") { 162 | const chunk = getNextChunk(); 163 | 164 | if (chunk === null) { 165 | state = "DONE"; 166 | } else if (isLineBreak(chunk)) { 167 | lines.push(currentLine); 168 | currentLine = ""; 169 | nextLine = ""; 170 | state = "NEW_LINE"; 171 | } else if (charsFit(chunk)) { 172 | currentLine += chunk; 173 | nextLine = ""; 174 | state = "EXISTING_LINE"; 175 | } else if (!charsFitEntirelyOnNextLine(chunk)) { 176 | nextLine = addCharsWithHyphen(chunk); 177 | lines.push(currentLine); 178 | currentLine = ""; 179 | state = "NEW_LINE"; 180 | } else { 181 | lines.push(currentLine); 182 | currentLine = ""; 183 | nextLine = chunk; 184 | state = "NEW_LINE"; 185 | } 186 | } else if (state === "DONE") { 187 | if (currentLine.length > 0) { 188 | lines.push(currentLine); 189 | } 190 | break; 191 | } else { 192 | throw "wut"; 193 | } 194 | } 195 | 196 | return lines; 197 | })(); 198 | 199 | const svg = makeSVG({ 200 | size, 201 | message, 202 | lines, 203 | fromAddress, 204 | toAddress, 205 | isPreview, 206 | }); 207 | svg.documentElement.setAttribute("width", d(width)); 208 | svg.documentElement.setAttribute("height", d(height)); 209 | 210 | // Render SVG to PNG blob 211 | const preset = presets.offscreen(); 212 | const canvas = new OffscreenCanvas(d(width), d(height)); 213 | const ctx = canvas.getContext("2d"); 214 | const canvasThing = new Canvg(ctx, svg, preset); 215 | await canvasThing.render(); 216 | return canvas.convertToBlob(); 217 | } 218 | 219 | function makeSVG({ size, message, lines, fromAddress, toAddress, isPreview }) { 220 | const { width, height } = size; 221 | 222 | // Make SVG in React 223 | const startingSvgReact = ( 224 | 230 | 237 | {" "} 238 | 239 | 240 | {lines.map((line, idx) => ( 241 | 247 | {line} 248 | 249 | ))} 250 | 251 | {isPreview && ( 252 | 262 | )} 263 | 264 | {isPreview && ( 265 | 275 | )} 276 | 277 | {isPreview && ( 278 | 283 | {formatAddress(fromAddress, ADDRESS_FROM_FONT_SIZE)} 284 | 285 | )} 286 | 287 | {isPreview && ( 288 | 293 | {formatAddress(toAddress, ADDRESS_TO_FONT_SIZE)} 294 | 295 | )} 296 | 297 | ); 298 | 299 | // Convert into SVG string 300 | const startingSvgStr = 301 | `` + 302 | ReactDomServer.renderToStaticMarkup(startingSvgReact); 303 | 304 | // Parse into SVG document 305 | const parser = new DOMParser(); 306 | return parser.parseFromString(startingSvgStr, "image/svg+xml"); 307 | } 308 | 309 | function formatAddress(a, fontSize) { 310 | const cityStateZip = 311 | `${a.addressCity}, ${a.addressState} ${a.addressZip}`.toUpperCase(); 312 | 313 | if (a.addressLine2) { 314 | return ( 315 | 316 | {a.addressName.toUpperCase()} 317 | {a.addressLine1.toUpperCase()} 318 | {a.addressLine2.toUpperCase()} 319 | {cityStateZip} 320 | 321 | ); 322 | } else { 323 | return ( 324 | 325 | {a.addressName.toUpperCase()} 326 | {a.addressLine1.toUpperCase()} 327 | {cityStateZip} 328 | 329 | ); 330 | } 331 | } 332 | -------------------------------------------------------------------------------- /src/lib/render-front.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Canvg, presets } from "canvg"; 3 | import ReactDomServer from "react-dom/server"; 4 | 5 | export default async function renderFront({ 6 | image, 7 | size, 8 | isPreview, 9 | scale = 300, 10 | }) { 11 | const { width, height } = size; 12 | const d = (v) => (v * scale).toFixed(6); 13 | 14 | const rotate = image.width < image.height; 15 | 16 | let trimSides, degrees, scaled, translate; 17 | if (rotate) { 18 | trimSides = image.width / image.height < height / width; 19 | degrees = [90, image.height / 2, image.height / 2]; 20 | scaled = trimSides ? d(height) / image.width : d(width) / image.height; 21 | translate = trimSides 22 | ? [-((scaled * image.height - d(width)) / 2), 0] 23 | : [0, -((scaled * image.width - d(height)) / 2)]; 24 | } else { 25 | trimSides = image.width / image.height > width / height; 26 | degrees = [0]; 27 | scaled = trimSides ? d(height) / image.height : d(width) / image.width; 28 | translate = trimSides 29 | ? [-((scaled * image.width - d(width)) / 2), 0] 30 | : [0, -((scaled * image.height - d(height)) / 2)]; 31 | } 32 | 33 | // Make SVG in React 34 | const svgReact = ( 35 | 41 | 47 | 48 | ); 49 | 50 | // Convert into SVG string 51 | const svgStr = 52 | `` + 53 | ReactDomServer.renderToStaticMarkup(svgReact); 54 | 55 | // Parse into SVG document 56 | const parser = new DOMParser(); 57 | const svg = parser.parseFromString(svgStr, "image/svg+xml"); 58 | 59 | // Render SVG to PNG blob 60 | const preset = presets.offscreen(); 61 | const canvas = new OffscreenCanvas(d(width), d(height)); 62 | const ctx = canvas.getContext("2d"); 63 | const canvasThing = new Canvg(ctx, svg, preset); 64 | await canvasThing.render(); 65 | return await canvas.convertToBlob(); 66 | } 67 | -------------------------------------------------------------------------------- /src/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scott113341/post/5a6c2e14fa31eba69fdee310ea5d06359e843318/src/static/favicon.png -------------------------------------------------------------------------------- /src/static/reset.css: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | div, 4 | span, 5 | applet, 6 | object, 7 | iframe, 8 | h1, 9 | h2, 10 | h3, 11 | h4, 12 | h5, 13 | h6, 14 | p, 15 | blockquote, 16 | pre, 17 | a, 18 | abbr, 19 | acronym, 20 | address, 21 | big, 22 | cite, 23 | code, 24 | del, 25 | dfn, 26 | em, 27 | img, 28 | ins, 29 | kbd, 30 | q, 31 | s, 32 | samp, 33 | small, 34 | strike, 35 | strong, 36 | sub, 37 | sup, 38 | tt, 39 | var, 40 | b, 41 | u, 42 | i, 43 | center, 44 | dl, 45 | dt, 46 | dd, 47 | ol, 48 | ul, 49 | li, 50 | fieldset, 51 | form, 52 | label, 53 | legend, 54 | table, 55 | caption, 56 | tbody, 57 | tfoot, 58 | thead, 59 | tr, 60 | th, 61 | td, 62 | article, 63 | aside, 64 | canvas, 65 | details, 66 | embed, 67 | figure, 68 | figcaption, 69 | footer, 70 | header, 71 | hgroup, 72 | menu, 73 | nav, 74 | output, 75 | ruby, 76 | section, 77 | summary, 78 | time, 79 | mark, 80 | audio, 81 | video { 82 | margin: 0; 83 | padding: 0; 84 | border: 0; 85 | font-size: 100%; 86 | font: inherit; 87 | vertical-align: baseline; 88 | } 89 | article, 90 | aside, 91 | details, 92 | figcaption, 93 | figure, 94 | footer, 95 | header, 96 | hgroup, 97 | menu, 98 | nav, 99 | section { 100 | display: block; 101 | } 102 | body { 103 | line-height: 1; 104 | } 105 | ol, 106 | ul { 107 | list-style: none; 108 | } 109 | blockquote, 110 | q { 111 | quotes: none; 112 | } 113 | blockquote:before, 114 | blockquote:after, 115 | q:before, 116 | q:after { 117 | content: ""; 118 | content: none; 119 | } 120 | table { 121 | border-collapse: collapse; 122 | border-spacing: 0; 123 | } 124 | -------------------------------------------------------------------------------- /src/static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /src/steps/FromAddressStep.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { 4 | Cell, 5 | Button, 6 | Link, 7 | NewAddressModal, 8 | Spacer, 9 | Step, 10 | } from "../components/index.js"; 11 | 12 | export default class FromAddressStep extends React.Component { 13 | render() { 14 | const address = this.props.postcard.address; 15 | const disabled = !this.isValid(); 16 | 17 | const modal = ( 18 | 23 | ); 24 | 25 | return ( 26 | 27 | {address.addresses.map((addressOption, index) => { 28 | const selected = index === address.selectedFromIndex; 29 | const deleteButtonSpacer = selected ? : null; 30 | const deleteButton = selected ? ( 31 | 34 | ) : null; 35 | return ( 36 | this.handleClickAddress(index)} 39 | selected={selected} 40 | > 41 |

{addressOption.addressName}

42 |

{addressOption.addressLine1}

43 |

{addressOption.addressLine2}

44 |

{`${addressOption.addressCity}, ${addressOption.addressState} ${addressOption.addressZip}`}

45 | {deleteButtonSpacer} 46 | {deleteButton} 47 |
48 | ); 49 | })} 50 | 51 | 52 |

new address

53 |
54 | 55 | {modal} 56 | 57 | 58 | this.props.goToStep("back")}>back 59 | this.props.goToStep("next")} disabled={disabled}> 60 | next 61 | 62 |
63 | ); 64 | } 65 | 66 | isValid() { 67 | return this.props.postcard.address.selectedFromIndex >= 0; 68 | } 69 | 70 | handleClickAddress = (index) => { 71 | this.props.changeSelectedAddress("from", index); 72 | }; 73 | 74 | handleClickDeleteAddress = (index, e) => { 75 | e.stopPropagation(); 76 | this.props.deleteAddress(index); 77 | }; 78 | 79 | handleClickNewAddress = () => { 80 | this.props.changeSelectedAddress("from", -1); 81 | this.props.showNewAddressModal(true); 82 | }; 83 | 84 | handleClickCancelModal = () => { 85 | this.props.showNewAddressModal(false); 86 | }; 87 | 88 | handleClickSaveModal = (address) => { 89 | this.props.addAddress(address); 90 | const newAddressIndex = this.props.postcard.address.addresses.length; 91 | this.props.changeSelectedAddress("from", newAddressIndex); 92 | this.props.showNewAddressModal(false); 93 | }; 94 | } 95 | -------------------------------------------------------------------------------- /src/steps/ImageStep.js: -------------------------------------------------------------------------------- 1 | import csjs from "csjs-inject"; 2 | import React from "react"; 3 | 4 | import { loadFileAsDataUrl, loadImageFromData } from "../util.js"; 5 | import { Button, Link, Spacer, Step } from "../components/index.js"; 6 | 7 | export default class ImageStep extends React.Component { 8 | render() { 9 | const image = this.props.postcard.image; 10 | const disabled = !this.isValid(); 11 | 12 | const img = image?.data?.length ? ( 13 |
14 | 15 | 16 |
17 | ) : null; 18 | 19 | return ( 20 | 21 | 22 | 29 | {img} 30 | 31 | this.props.goToStep("back")}>back 32 | this.props.goToStep("next")} disabled={disabled}> 33 | next 34 | 35 | 36 | ); 37 | } 38 | 39 | isValid = () => { 40 | return this.props.postcard.image?.data?.indexOf("data:image") === 0; 41 | }; 42 | 43 | setFileInputRef = (e) => { 44 | this.fileInput = e; 45 | }; 46 | 47 | handleBrowseButtonClick = () => { 48 | this.fileInput.click(); 49 | }; 50 | 51 | handleImageLoad = async (e) => { 52 | this.props.changeImage(this.props.postcard.initialState.image); 53 | const file = e.target.files[0]; 54 | const data = await loadFileAsDataUrl(file); 55 | const image = await loadImageFromData(data); 56 | this.props.changeImage({ 57 | data, 58 | width: image.width, 59 | height: image.height, 60 | }); 61 | }; 62 | } 63 | 64 | export const styles = csjs` 65 | .input { 66 | border: none; 67 | overflow: hidden; 68 | position: absolute; 69 | width: 0; 70 | z-index: -1; 71 | } 72 | 73 | .image { 74 | max-height: 300px; 75 | max-width: 100%; 76 | } 77 | `; 78 | -------------------------------------------------------------------------------- /src/steps/LobStep.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Input, Link, Spacer, Step } from "../components/index.js"; 4 | 5 | export default class LobStep extends React.Component { 6 | render() { 7 | const apiKey = this.props.postcard.lob.apiKey; 8 | const disabled = !this.isValid(); 9 | 10 | return ( 11 | 12 |

enter your lob api key

13 | 14 | 19 | 20 | this.props.goToStep("back")}>back 21 | this.props.goToStep("next")} disabled={disabled}> 22 | next 23 | 24 |
25 | ); 26 | } 27 | 28 | isValid() { 29 | return this.props.postcard.lob.apiKey.length === 40; 30 | } 31 | 32 | handleApiKeyInputChange = (apiKey) => { 33 | this.props.changeLobApiKey(apiKey); 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /src/steps/MessageStep.js: -------------------------------------------------------------------------------- 1 | import csjs from "csjs-inject"; 2 | import React from "react"; 3 | 4 | import { Link, Spacer, Step } from "../components/index.js"; 5 | 6 | export default class MessageStep extends React.Component { 7 | render() { 8 | const message = this.props.postcard.message; 9 | 10 | return ( 11 | 12 |