├── .arc
├── .gitignore
└── app
├── .gitignore
├── .vscode
└── settings.json
├── components
├── GenericTable.tsx
├── Labels.tsx
├── Layout.tsx
├── OrderTable.tsx
├── OrderVariations.tsx
└── Scanner.tsx
├── hooks
├── useBatch.ts
├── useFocus.tsx
└── useShipment.ts
├── interfaces
├── chitchat.d.ts
├── index.ts
└── snipcart.d.ts
├── next-env.d.ts
├── package-lock.json
├── package.json
├── pages
├── _app.tsx
├── _document.tsx
├── api
│ ├── auth
│ │ └── [...nextauth].ts
│ ├── batches
│ │ ├── [id].ts
│ │ └── index.ts
│ ├── orders
│ │ ├── [token].ts
│ │ ├── index.ts
│ │ └── refetch-metadata.ts
│ ├── products
│ │ └── index.ts
│ ├── shipments
│ │ ├── [id].ts
│ │ ├── add_to_batch.ts
│ │ └── index.ts
│ ├── shipping
│ │ └── index.ts
│ ├── snipcart
│ │ └── [...proxy].ts
│ ├── test
│ │ └── index.ts
│ └── webhook
│ │ └── index.ts
├── auth
│ └── index.tsx
├── batches
│ ├── [batchId].tsx
│ └── index.tsx
├── index.tsx
└── orders.tsx
├── public
└── beep.wav
├── readme.md
├── tsconfig.json
└── utils
├── chitchats.ts
├── getShippingQuote.ts
├── initMiddleware.ts
├── snipCart.ts
├── snipCartAPI.ts
├── stallion.ts
└── withAuth.ts
/.arc:
--------------------------------------------------------------------------------
1 | @app
2 | chit-chats
3 |
4 | @http
5 | get /
6 | /shipping
7 | method get
8 | src src/http/get-shipping
9 | /shipping
10 | method post
11 | src src/http/get-shipping
12 | post /webhook
13 |
14 | @aws
15 | # profile default
16 | # region us-west-1
17 | timeout 55
18 |
19 | @tables
20 | data
21 | scopeID *String
22 | dataID **String
23 | ttl TTL
24 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | haters
2 | .DS_Store
3 | dist/
4 | /src/http/**/*.js
5 |
6 | # Logs
7 | logs
8 | *.log
9 | npm-debug.log*
10 | yarn-debug.log*
11 | yarn-error.log*
12 | lerna-debug.log*
13 |
14 | # Diagnostic reports (https://nodejs.org/api/report.html)
15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
16 |
17 | # Runtime data
18 | pids
19 | *.pid
20 | *.seed
21 | *.pid.lock
22 |
23 | # Directory for instrumented libs generated by jscoverage/JSCover
24 | lib-cov
25 |
26 | # Coverage directory used by tools like istanbul
27 | coverage
28 | *.lcov
29 |
30 | # nyc test coverage
31 | .nyc_output
32 |
33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
34 | .grunt
35 |
36 | # Bower dependency directory (https://bower.io/)
37 | bower_components
38 |
39 | # node-waf configuration
40 | .lock-wscript
41 |
42 | # Compiled binary addons (https://nodejs.org/api/addons.html)
43 | build/Release
44 |
45 | # Dependency directories
46 | node_modules/
47 | jspm_packages/
48 |
49 | # Snowpack dependency directory (https://snowpack.dev/)
50 | web_modules/
51 |
52 | # TypeScript cache
53 | *.tsbuildinfo
54 |
55 | # Optional npm cache directory
56 | .npm
57 |
58 | # Optional eslint cache
59 | .eslintcache
60 |
61 | # Microbundle cache
62 | .rpt2_cache/
63 | .rts2_cache_cjs/
64 | .rts2_cache_es/
65 | .rts2_cache_umd/
66 |
67 | # Optional REPL history
68 | .node_repl_history
69 |
70 | # Output of 'npm pack'
71 | *.tgz
72 |
73 | # Yarn Integrity file
74 | .yarn-integrity
75 |
76 | # dotenv environment variables file
77 | .env
78 | .env.test
79 |
80 | # parcel-bundler cache (https://parceljs.org/)
81 | .cache
82 | .parcel-cache
83 |
84 | # Next.js build output
85 | .next
86 | out
87 |
88 | # Nuxt.js build / generate output
89 | .nuxt
90 | dist
91 |
92 | # Gatsby files
93 | .cache/
94 | # Comment in the public line in if your project uses Gatsby and not Next.js
95 | # https://nextjs.org/blog/next-9-1#public-directory-support
96 | # public
97 |
98 | # vuepress build output
99 | .vuepress/dist
100 |
101 | # Serverless directories
102 | .serverless/
103 |
104 | # FuseBox cache
105 | .fusebox/
106 |
107 | # DynamoDB Local files
108 | .dynamodb/
109 |
110 | # TernJS port file
111 | .tern-port
112 |
113 | # Stores VSCode versions used for testing VSCode extensions
114 | .vscode-test
115 |
116 | # yarn v2
117 | .yarn/cache
118 | .yarn/unplugged
119 | .yarn/build-state.yml
120 | .yarn/install-state.gz
121 | .pnp.*
122 |
123 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
124 |
125 | # dependencies
126 | /node_modules
127 | /.pnp
128 | .pnp.js
129 |
130 | # testing
131 | /coverage
132 |
133 | # next.js
134 | /.next/
135 | /out/
136 |
137 | # production
138 | /build
139 |
140 | # misc
141 | .DS_Store
142 | *.pem
143 |
144 | # debug
145 | npm-debug.log*
146 | yarn-debug.log*
147 | yarn-error.log*
148 |
149 | # local env files
150 | .env.local
151 | .env.development.local
152 | .env.test.local
153 | .env.production.local
154 |
155 | # vercel
156 | .vercel
157 |
--------------------------------------------------------------------------------
/app/.gitignore:
--------------------------------------------------------------------------------
1 | *.env.*
2 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
3 |
4 | # dependencies
5 | /node_modules
6 | /.pnp
7 | .pnp.js
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env.local
30 | .env.development.local
31 | .env.test.local
32 | .env.production.local
33 |
34 | # vercel
35 | .vercel
36 |
--------------------------------------------------------------------------------
/app/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "cSpell.words": [
3 | "snipcart"
4 | ]
5 | }
--------------------------------------------------------------------------------
/app/components/GenericTable.tsx:
--------------------------------------------------------------------------------
1 | import styled from 'styled-components';
2 |
3 | const TableStyles = styled.table`
4 | width: 100%;
5 | font-family: sans-serif;
6 | border-collapse: collapse;
7 | td {
8 | padding: 3px;
9 | border: 1px solid #ededed;
10 | }
11 | th {
12 | text-align: left;
13 | background: black;
14 | color white;
15 | }
16 | tr:nth-child(even) {
17 | background: #efefef;
18 | }
19 | `;
20 | type GenericTableProps = {
21 | data: any[];
22 | columns: string[];
23 | };
24 |
25 | export default function GenericTable({
26 | data = [],
27 | columns = [],
28 | }: GenericTableProps) {
29 | return (
30 | <>
31 |
32 |
33 |
34 | {columns.map((heading) => (
35 | {heading} |
36 | ))}
37 |
38 |
39 |
40 | {data.map((item) => (
41 |
42 | {columns.map((heading) => (
43 |
44 | {item[heading]}
45 | |
46 | ))}
47 |
48 | ))}
49 |
50 |
51 | >
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/app/components/Labels.tsx:
--------------------------------------------------------------------------------
1 | import QRCode from 'qrcode.react';
2 | import { Fragment } from 'react';
3 | import styled from 'styled-components';
4 | import { SnipCartOrder } from '../interfaces/snipcart';
5 |
6 | export const LabelStyles = styled.div`
7 | width: 4in;
8 | height: 6in;
9 | margin: 20px 0;
10 | @media print {
11 | width: 100%;
12 | height: 100vh;
13 | margin: 0;
14 | border: 0;
15 | }
16 | font-family: 'Operator Mono';
17 | border: 4px solid black;
18 | img {
19 | width: 100%;
20 | height: 100%;
21 | }
22 | p {
23 | margin: 0;
24 | margin-bottom: 5px;
25 | }
26 | .label-header {
27 | display: grid;
28 | grid-template-columns: 1fr auto;
29 | justify-content: center;
30 | align-items: center;
31 | text-align: left;
32 | background: black;
33 | color: white;
34 | font-style: italic;
35 | font-size: 15px;
36 | padding-left: 20px;
37 | canvas {
38 | width: 100%;
39 | border: 2px solid white;
40 | }
41 | h2 {
42 | margin: 0;
43 | font-size: 1rem;
44 | }
45 | }
46 | ol {
47 | list-style-type: decimal-leading-zero;
48 | margin: 0;
49 | li {
50 | border-bottom: 1px solid black;
51 | padding: 2px;
52 | }
53 | }
54 | h3 {
55 | margin: 0;
56 | margin-bottom: 5px;
57 | }
58 | .label-footer {
59 | background: black;
60 | color: white;
61 | text-align: center;
62 | text-transform: uppercase;
63 | p {
64 | margin: 2px;
65 | }
66 | }
67 |
68 | .meta {
69 | display: flex;
70 | font-size: 13px;
71 | font-family: --apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
72 | Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
73 | gap: 10px;
74 | }
75 | &.packing {
76 | display: grid;
77 | grid-template-rows: auto 1fr auto;
78 | }
79 | &.shipping {
80 | display: grid;
81 | grid-template-rows: 50px 1fr;
82 | img {
83 | height: 100%;
84 | min-height: 0;
85 | }
86 | canvas {
87 | border: 2px solid white;
88 | }
89 | }
90 | `;
91 |
92 | const LabelsGrid = styled.div`
93 | & > div {
94 | display: grid;
95 | grid-template-columns: 1fr 1fr;
96 | @media print {
97 | display: block;
98 | }
99 | }
100 | `;
101 |
102 | function PackingList({ order }: { order: SnipCartOrder }) {
103 | const { items } = order;
104 | return (
105 |
106 |
107 |
108 | Order {order.invoiceNumber}
109 |
110 | Shipment {order?.metadata?.chitChatId}
111 |
112 | {order.billingAddressName}
113 |
114 |
115 |
116 |
117 | {items?.map((item, i) => (
118 | -
119 |
{item.name}
120 |
121 |
122 | Qty: {item.quantity}
123 |
124 |
125 | {item.customFields?.map((field) => (
126 |
127 | {field.name}:{' '}
128 | {field.displayValue}
129 |
130 | ))}
131 |
132 |
133 | ID: {item.id}
134 |
135 |
136 |
137 | ))}
138 |
139 | {order.numberOfItemsInOrder} Item
140 | {order.numberOfItemsInOrder === 1 ? '' : 's'} Total
141 |
142 |
143 |
144 |
Thank You × You are a good dev × Wes Bos
145 |
146 |
147 | );
148 | }
149 |
150 | function ShippingLabel({ order }: { order: SnipCartOrder }) {
151 | return (
152 |
153 |
154 |
155 |
156 | );
157 | }
158 |
159 | type OrdersProps = {
160 | orders?: SnipCartOrder[];
161 | };
162 |
163 | export function Labels({ orders }: OrdersProps) {
164 | if (!orders) return No orders to show
;
165 | return (
166 |
167 | {orders.map((order) => (
168 |
172 | ))}
173 |
174 | );
175 | }
176 |
--------------------------------------------------------------------------------
/app/components/Layout.tsx:
--------------------------------------------------------------------------------
1 | import React, { ReactNode } from 'react';
2 | import Link from 'next/link';
3 | import Head from 'next/head';
4 | import styled, { createGlobalStyle } from 'styled-components';
5 | import 'normalize.css';
6 | import { useIsFetching } from 'react-query';
7 | import nProgress from 'nprogress';
8 | import { useSession } from 'next-auth/client';
9 | import { Scanner } from './Scanner';
10 |
11 | type Props = {
12 | children?: ReactNode;
13 | title?: string;
14 | };
15 |
16 | const GlobalStyles = createGlobalStyle`
17 | :root {
18 | --black: black;
19 | --yellow: #ffc600;
20 | --light: #ffffff;
21 | --dark: #000000;
22 | --lightGrey: #d8d8d8;
23 | --backgroundGrey: #f7f7f7;
24 | --lightGray: var(--lightGrey);
25 | --imGoingToFaint: #fbfbfb;
26 | --maxWidth: 1200px;
27 | }
28 |
29 | html {
30 | box-sizing: border-box;
31 | font-family: 'Operator Mono', --apple-system, BlinkMacSystemFont, 'Segoe UI',
32 | Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue',
33 | sans-serif;
34 | }
35 | a {
36 | text-decoration-color: var(--yellow);
37 | color: var(--black);
38 | }
39 | button {
40 | background: var(--yellow);
41 | border: 0;
42 | padding: 5px;
43 | border-radius: 2px;
44 | }
45 | *,
46 | &::before,
47 | *::after {
48 | box-sizing: inherit;
49 | }
50 |
51 | @media print {
52 | header,
53 | footer {
54 | display: none;
55 | }
56 | @page {
57 | size: 4in 6in;
58 | margin: 0;
59 | }
60 | .no-print {
61 | display: none;
62 | }
63 | }
64 | // Base Table styles
65 | table {
66 | width: 100%;
67 | border: 1px solid black;
68 | font-family: sans-serif;
69 | img {
70 | float: left;
71 | border-radius: 50%;
72 | margin-right: 10px;
73 | }
74 | td {
75 | padding: 2px;
76 | vertical-align: middle;
77 | max-width: 150px;
78 | overflow: scroll;
79 | text-overflow: ellipsis;
80 | }
81 | }
82 | tr:nth-child(even) {
83 | background: var(--backgroundGrey);
84 | }
85 |
86 | // nprogress
87 | #nprogress {
88 | @media print {
89 | display: none;
90 | }
91 | .bar {
92 | height: 4px;
93 | background: var(--yellow);
94 | }
95 | .peg {
96 | box-shadow: 0 0 10px var(--yellow), 0 0 5px var(--yellow);
97 | }
98 | .spinner-icon {
99 | border-top-color: var(--yellow);
100 | border-left-color: var(--yellow);
101 | }
102 | }
103 |
104 | .blurred .blur {
105 | filter: blur(4px);
106 | }
107 | .blurred span.blur {
108 | filter: blur(3px);
109 | }
110 | `;
111 |
112 | const LayoutStyles = styled.div`
113 | display: grid;
114 | min-height: 100vh;
115 | grid-template-rows: auto 1fr auto;
116 | max-width: 1000px;
117 | margin: 0 auto;
118 | border: 5px solid var(--black);
119 | padding: 2rem;
120 | @media print {
121 | border: 0;
122 | min-height: none;
123 | padding: 0;
124 | }
125 | `;
126 |
127 | const Logo = styled.h1`
128 | display: grid;
129 | grid-auto-flow: column;
130 | align-items: center;
131 | justify-content: start;
132 | gap: 2rem;
133 | `;
134 |
135 | const Nav = styled.nav`
136 | display: grid;
137 | grid-template-columns: auto auto auto 1fr;
138 | gap: 1rem;
139 | align-items: center;
140 | `;
141 |
142 | export default function Layout({
143 | children,
144 | title = 'This is the default title',
145 | }: Props) {
146 | const isFetching = useIsFetching();
147 | if (isFetching) {
148 | nProgress.start();
149 | } else {
150 | nProgress.done();
151 | }
152 | const [session, loading] = useSession();
153 | if (loading) return Loading..
;
154 | // dont worry we also lock down the data in the API endpoints, this is just visual.
155 | if (!session)
156 | return (
157 |
158 |
VERCEL_ENV: {process.env.VERCEL_ENV}
159 |
NODE_ENV: {process.env.NODE_ENV}
160 |
161 |
Login
162 |
163 |
164 | );
165 | return (
166 |
167 |
168 |
169 |
170 | {isFetching} {title}
171 |
172 |
173 |
174 |
175 |
176 |
177 |
182 | Swag Shop.
183 |
184 |
190 |
191 | {children}
192 |
193 |
194 | );
195 | }
196 |
--------------------------------------------------------------------------------
/app/components/OrderTable.tsx:
--------------------------------------------------------------------------------
1 | import QRCode from 'qrcode.react';
2 | import { ChangeEvent, useState } from 'react';
3 | import { useMutation } from 'react-query';
4 | import { MetaData, SnipCartOrder } from '../interfaces/snipcart';
5 | import { OrderVariations } from './OrderVariations';
6 |
7 | type OrdersProps = {
8 | orders: SnipCartOrder[];
9 | };
10 |
11 | interface SnipCartMarkOrderArgs {
12 | token: string;
13 | }
14 |
15 | function generateSnipCartUrl(order: SnipCartOrder) {
16 | return `https://app.snipcart.com/dashboard/orders/${order.token}`;
17 | }
18 | function generateChitChatUrl(order: SnipCartOrder) {
19 | return `https://chitchats.com/clients/408432/shipments/search?q=${
20 | order.metadata?.chitChatId as string
21 | }`;
22 | }
23 |
24 | function useFilters(initialData: MetaData) {
25 | const [filters, setFilters] = useState>(initialData);
26 | function handleFilterChange(e: ChangeEvent) {
27 | console.log(e.target.value);
28 | setFilters({
29 | ...filters,
30 | [e.currentTarget.name]: e.currentTarget.checked,
31 | });
32 | }
33 | return {
34 | handleFilterChange,
35 | filters,
36 | };
37 | }
38 |
39 | function OrderRow({ order }: { order: SnipCartOrder }) {
40 | const mutation = useMutation(
41 | ({ token }) =>
42 | fetch(`/api/orders/${token}`, {
43 | method: 'POST',
44 | }).then((x) => x.json() as Promise)
45 | );
46 |
47 | const refetchMetaData = useMutation<
48 | SnipCartOrder,
49 | any,
50 | SnipCartMarkOrderArgs
51 | >(({ token }) =>
52 | fetch(`/api/orders/refetch-metadata?token=${token}`, {
53 | method: 'POST',
54 | }).then((x) => x.json() as Promise)
55 | );
56 |
57 | const sendTrackingInfo = useMutation<
58 | SnipCartOrder,
59 | any,
60 | SnipCartMarkOrderArgs
61 | >(({ token }) =>
62 | fetch(`/api/snipcart/orders/${token}/notifications`, {
63 | method: 'POST',
64 | body: JSON.stringify({
65 | type: 'TrackingNumber',
66 | message: 'Test Message',
67 | deliveryMethod: 'Email',
68 | }),
69 | }).then((x) => x.json() as Promise)
70 | );
71 |
72 | return (
73 |
74 |
75 |
76 |
77 | {order.user.billingAddressName}
78 |
79 | {order.email}
80 |
81 | |
82 | ${order.finalGrandTotal} |
83 | {order.status} |
84 |
85 |
90 | SNIP
91 |
92 | |
93 |
94 |
99 | {order.metadata?.chitChatId}
100 |
101 | |
102 |
103 | {order.metadata?.label ? (
104 | 'YES'
105 | ) : (
106 | NO
107 | )}
108 | |
109 | {order.trackingNumber} |
110 |
111 |
112 | |
113 |
114 |
124 |
125 |
134 |
143 | |
144 |
145 | );
146 | }
147 |
148 | export function OrderTable({ orders }: OrdersProps) {
149 | const { filters, handleFilterChange } = useFilters({ noLabel: false });
150 | const ordersToShow =
151 | orders && filters.noLabel
152 | ? orders.filter((order) => !order.metadata?.label)
153 | : orders;
154 |
155 | if (!ordersToShow) return No orders to show
;
156 |
157 | return (
158 |
159 |
160 |
170 |
171 | Showing {ordersToShow.length} of {orders.length}
172 |
173 |
174 |
175 |
176 | Name |
177 | Amount |
178 | Status |
179 | Snipcart |
180 | ChitChat ID |
181 | Label |
182 | Track |
183 | Token |
184 | Actions |
185 |
186 |
187 |
188 | {ordersToShow.map((order) => (
189 |
190 | ))}
191 |
192 |
193 |
194 | );
195 | }
196 |
--------------------------------------------------------------------------------
/app/components/OrderVariations.tsx:
--------------------------------------------------------------------------------
1 | // {
2 | // "pink-on-pink": {
3 | // s: 20,
4 | // },
5 | // "black-on-black": {
6 | // L: 20
7 | // }
8 | // }
9 |
10 | import { SnipCartOrder, SnipCartOrderItem } from '../interfaces/snipcart';
11 |
12 | type OrdersProps = {
13 | orders: SnipCartOrder[];
14 | };
15 |
16 | interface VariationStock {
17 | [key: string]: {
18 | [customField: string]: {
19 | [variantion: string]: number;
20 | };
21 | };
22 | }
23 |
24 | export function OrderVariations({ orders }: OrdersProps) {
25 | const itemsSold = orders.map((order) => order.items);
26 | const items = itemsSold.flat() as SnipCartOrderItem[];
27 | const stockLevels = items.reduce((acc: VariationStock, item) => {
28 | if (!item.customFields) return acc;
29 | const product = acc[item.id] || {};
30 | item.customFields.forEach((customField) => {
31 | // Variant is "Size"
32 | const variant = product[customField.name] || {};
33 | // This is like Size.XL = xx;
34 | variant[customField.value] = variant[customField.value] + 1 || 1;
35 | product[customField.name] = variant;
36 | });
37 | acc[item.id] = product;
38 | return acc;
39 | }, {});
40 |
41 | return (
42 |
43 |
{JSON.stringify(stockLevels, null, ' ')}
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/app/components/Scanner.tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/dist/client/router';
2 | import nProgress from 'nprogress';
3 | import { ChangeEvent, useRef, useState } from 'react';
4 | import { useQueryClient } from 'react-query';
5 | import styled from 'styled-components';
6 | import wait from 'waait';
7 | import { useAddToBatch } from '../hooks/useBatch';
8 | import { useFocus } from '../hooks/useFocus';
9 |
10 | const ScannerStyles = styled.div`
11 | width: 100%;
12 | top: 0;
13 | width: 100%;
14 | background: white;
15 | display: grid;
16 | grid-template-columns: 1fr auto;
17 | align-items: center;
18 | input[type='text'] {
19 | width: 100%;
20 | padding: 10px 20px;
21 | border-radius: 20px;
22 | border: 2px solid var(--lightGrey);
23 | &:focus {
24 | outline: none;
25 | border-color: var(--yellow);
26 | }
27 | }
28 | input[type='radio'] {
29 | margin: 0 10px;
30 | background: black;
31 | border: 2px solid black;
32 | }
33 | `;
34 |
35 | export function Scanner() {
36 | const audioRef = useRef(null);
37 | const inputRef = useRef(null);
38 | useFocus(inputRef);
39 | const router = useRouter();
40 | const [action, setAction] = useState('goto');
41 | const batchMutation = useAddToBatch();
42 | const queryClient = useQueryClient();
43 |
44 | async function handleScan(e: React.FormEvent) {
45 | e.preventDefault();
46 |
47 | nProgress.start();
48 | const form = e.currentTarget;
49 | const element = e.currentTarget.barcodeValue as HTMLInputElement;
50 | const { value } = element;
51 | if (value.startsWith('batch:')) {
52 | const [, id] = value.split(':');
53 | router.push(`/batches/${id}`);
54 | } else if (/* action === 'ship' && */ router.query.batchId) {
55 | const batchId = Array.isArray(router.query.batchId)
56 | ? router.query.batchId[0]
57 | : router.query.batchId;
58 | if (!batchId) {
59 | throw new Error('No Batch ID Present');
60 | }
61 | console.log('GOTTA SHIP IT', { value, batchId });
62 |
63 | // 1. Get shipment from Snipcart
64 | const order = await fetch(`/api/orders/${value}`).then((res) =>
65 | res.json()
66 | );
67 | // from the Snipcart order we get the Chit Chat ID
68 | const { chitChatId } = order.metadata;
69 | console.log('Got the Chit Chat ID: ', chitChatId);
70 | // 2. Add to Chit Chat batch
71 | await batchMutation
72 | .mutateAsync({
73 | batch_id: batchId,
74 | shipment_ids: [chitChatId],
75 | })
76 | .catch((err) => {
77 | console.log(err);
78 | throw new Error(err);
79 | console.log('That one didnt work');
80 | });
81 | // 3. Refresh the orders in this batch
82 | console.log('Refreshing the batch', batchId);
83 | await queryClient.refetchQueries(['shipments-in-batch', batchId]);
84 | console.log('DONE!');
85 | // 4. Mark it as shipped in Snipcart
86 | console.log('Marking as shipped in Snipcart');
87 | const updatedOrder = await fetch(`/api/orders/${value}`, {
88 | method: 'POST',
89 | }).then((res) => res.json());
90 | console.log(updatedOrder);
91 | // play a beep
92 | if (audioRef.current) {
93 | audioRef.current.currentTime = 0;
94 | audioRef.current.play();
95 | }
96 | nProgress.done();
97 | }
98 | await wait(100);
99 | form.reset();
100 | }
101 |
102 | function handleChange(e: ChangeEvent) {
103 | setAction(e.target.value);
104 | }
105 | return (
106 |
107 |
108 |
111 |
112 |
123 |
134 |
135 |
136 | );
137 | }
138 |
--------------------------------------------------------------------------------
/app/hooks/useBatch.ts:
--------------------------------------------------------------------------------
1 | import { useMutation, useQuery } from 'react-query';
2 | import {
3 | ChitChatBatch,
4 | ChitChatAddShipmentToBatchInput,
5 | } from '../utils/chitchats';
6 |
7 | export default function useBatch(batchId: string | string[] | undefined) {
8 | const { data = [], refetch } = useQuery(['shipments-in-batch', batchId], () =>
9 | fetch(`/api/shipments?batch_id=${batchId}`).then((res) => res.json())
10 | );
11 | return {
12 | shipments: data,
13 | refetchBatches: refetch,
14 | };
15 | }
16 |
17 | export function useAddToBatch() {
18 | const mutation = useMutation<
19 | ChitChatBatch,
20 | any,
21 | ChitChatAddShipmentToBatchInput
22 | >((body) =>
23 | fetch(`/api/shipments/add_to_batch`, {
24 | method: 'POST',
25 | body: JSON.stringify(body),
26 | }).then((x) => x.json())
27 | );
28 | return mutation;
29 | }
30 |
--------------------------------------------------------------------------------
/app/hooks/useFocus.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 |
3 | export function useFocus(ref: React.RefObject) {
4 | useEffect(() => {
5 | const interval = setInterval(() => {
6 | if (ref?.current && window.scrollY < 200) {
7 | ref?.current?.focus();
8 | }
9 | }, 1000);
10 | return () => clearInterval(interval);
11 | }, [ref]);
12 | }
13 |
--------------------------------------------------------------------------------
/app/hooks/useShipment.ts:
--------------------------------------------------------------------------------
1 | import { useQuery } from 'react-query';
2 |
3 | export function useShipment(shipmentId: string) {
4 | const { data = [], refetch } = useQuery(['shipment', shipmentId], () =>
5 | fetch(`/api/shipment/${shipmentId}`).then((res) => res.json())
6 | );
7 | return {
8 | shipment: data,
9 | refetchBatches: refetch,
10 | };
11 | }
12 |
--------------------------------------------------------------------------------
/app/interfaces/chitchat.d.ts:
--------------------------------------------------------------------------------
1 | export interface Shipment {
2 | data?: {
3 | error?: {
4 | message?: string;
5 | };
6 | };
7 | id: string;
8 | status: string;
9 | batch_id?: null;
10 | to_name: string;
11 | to_address_1: string;
12 | to_address_2?: null;
13 | to_city: string;
14 | to_province_code: string;
15 | to_postal_code: string;
16 | to_country_code: string;
17 | to_phone?: null;
18 | return_name: string;
19 | return_address_1: string;
20 | return_address_2?: null;
21 | return_city: string;
22 | return_province_code: string;
23 | return_postal_code: string;
24 | return_country_code: string;
25 | return_phone: string;
26 | package_contents: string;
27 | description: string;
28 | value: string;
29 | value_currency: string;
30 | order_id?: string | null;
31 | order_store?: null;
32 | package_type: string;
33 | size_unit?: string | null;
34 | size_x: number;
35 | size_y: number;
36 | size_z: number;
37 | weight_unit: string;
38 | weight: number;
39 | is_insured: boolean;
40 | is_insurance_requested: boolean;
41 | is_media_mail_requested: boolean;
42 | is_signature_requested: boolean;
43 | postage_type: string;
44 | carrier: string;
45 | carrier_tracking_code?: string | null;
46 | tracking_url: string;
47 | ship_date: string;
48 | purchase_amount: string;
49 | provincial_tax?: null;
50 | provincial_tax_label?: null;
51 | federal_tax?: null;
52 | federal_tax_label?: null;
53 | postage_fee: string;
54 | insurance_fee?: string | null;
55 | delivery_fee?: string | null;
56 | created_at: string;
57 | postage_label_png_url: string;
58 | postage_label_zpl_url: string;
59 | rates?: RatesEntity[] | null;
60 | }
61 |
62 | export interface RatesEntity {
63 | postage_type: string;
64 | postage_carrier_type: string;
65 | postage_description: string;
66 | signature_confirmation_description?: null;
67 | delivery_time_description: string;
68 | tracking_type_description: string;
69 | is_insured: boolean;
70 | purchase_amount: string;
71 | provincial_tax?: null;
72 | provincial_tax_label?: null;
73 | federal_tax?: null;
74 | federal_tax_label?: null;
75 | postage_fee: string;
76 | insurance_fee?: string | null;
77 | delivery_fee?: string | null;
78 | payment_amount: string;
79 | }
80 |
81 | type PackageTypes =
82 | | 'unknown'
83 | | 'card'
84 | | 'letter'
85 | | 'envelope'
86 | | 'thick_envelope'
87 | | 'parcel'
88 | | 'flat_rate_envelope'
89 | | 'flat_rate_legal_envelope'
90 | | 'flat_rate_padded_envelope'
91 | | 'flat_rate_gift_card_envelope'
92 | | 'flat_rate_window_envelope'
93 | | 'flat_rate_cardboard_envelope'
94 | | 'small_flat_rate_envelope'
95 | | 'small_flat_rate_box'
96 | | 'medium_flat_rate_box_1'
97 | | 'medium_flat_rate_box_2'
98 | | 'large_flat_rate_box'
99 | | 'large_flat_rate_board_game_box'
100 | | 'regional_rate_box_a_1'
101 | | 'regional_rate_box_a_2'
102 | | 'regional_rate_box_b_1'
103 | | 'regional_rate_box_b_2';
104 |
105 | type PackageContents =
106 | | 'merchandise'
107 | | 'documents'
108 | | 'gift'
109 | | 'returned_goods'
110 | | 'sample'
111 | | 'other';
112 |
113 | type PostageType =
114 | | 'unknown'
115 | | 'usps_express'
116 | | 'usps_express_mail_international'
117 | | 'usps_first'
118 | | 'usps_first_class_mail_international'
119 | | 'usps_first_class_package_international_servic'
120 | | 'usps_library_mail'
121 | | 'usps_media_mail'
122 | | 'usps_parcel_select'
123 | | 'usps_priority'
124 | | 'usps_priority_mail_international'
125 | | 'usps_other'
126 | | 'ups_other'
127 | | 'fedex_other'
128 | | 'chit_chats_canada_tracked'
129 | | 'chit_chats_international_not_tracked'
130 | | 'chit_chats_us_tracked'
131 | | 'dhl_other'
132 | | 'asendia_priority_tracked'
133 | | 'ups_mi_expedited';
134 |
135 | export interface CreateShipmentInput {
136 | package_contents: PackageContents;
137 | package_type: PackageTypes;
138 | postage_type: PostageType;
139 | size_unit: 'm' | 'cm' | 'in';
140 | weight_unit: 'lb' | 'oz' | 'kg' | 'g';
141 | value_currency: 'cad' | 'usd';
142 | name: string;
143 | address_1: string;
144 | address_2?: string;
145 | city: string;
146 | province_code?: string;
147 | postal_code: string;
148 | country_code: string;
149 | phone?: string;
150 | description: string;
151 | value: string;
152 | order_id?: string;
153 | order_store?: string;
154 | weight: number;
155 | size_x: number;
156 | size_y: number;
157 | size_z: number;
158 | insurance_requested?: boolean;
159 | signature_requested?: boolean;
160 | tracking_number?: string;
161 | ship_date: string;
162 | }
163 |
164 | export interface ShippingRatesRequest {
165 | eventName: string;
166 | mode: string;
167 | createdOn: string;
168 | content: Content;
169 | }
170 |
171 | // This is a snipcart interface, no chitchats
172 | // TODO: Move to own file
173 | export interface Content {
174 | vnexT_MigrationFailed: boolean;
175 | token: string;
176 | isRecurringOrder: boolean;
177 | parentToken?: null;
178 | parentInvoiceNumber?: null;
179 | subscriptionId?: null;
180 | currency: string;
181 | creationDate: string;
182 | modificationDate: string;
183 | recoveredFromCampaignId?: null;
184 | status: string;
185 | paymentStatus?: null;
186 | email: string;
187 | willBePaidLater: boolean;
188 | billingAddress: BillingAddressOrShippingAddress;
189 | shippingAddress: BillingAddressOrShippingAddress;
190 | shippingAddressSameAsBilling: boolean;
191 | creditCardLast4Digits?: null;
192 | trackingNumber?: null;
193 | trackingUrl?: null;
194 | shippingFees: number;
195 | shippingProvider?: null;
196 | shippingMethod: string;
197 | cardHolderName?: null;
198 | paymentMethod: string;
199 | notes?: null;
200 | customFieldsJson: string;
201 | userId?: null;
202 | completionDate?: null;
203 | paymentGatewayUsed: string;
204 | paymentDetails: PaymentDetails;
205 | taxProvider: string;
206 | discounts?: null[] | null;
207 | plans?: null[] | null;
208 | taxes?: null[] | null;
209 | user?: null;
210 | items?: (null[] | null)[] | null;
211 | refunds?: null[] | null;
212 | lang: string;
213 | refundsAmount: number;
214 | adjustedAmount: number;
215 | finalGrandTotal: number;
216 | billingAddressFirstName?: null;
217 | billingAddressName: string;
218 | billingAddressCompanyName?: null;
219 | billingAddressAddress1: string;
220 | billingAddressAddress2: string;
221 | billingAddressCity: string;
222 | billingAddressCountry: string;
223 | billingAddressProvince: string;
224 | billingAddressPostalCode: string;
225 | billingAddressPhone?: null;
226 | shippingAddressFirstName?: null;
227 | shippingAddressName: string;
228 | shippingAddressCompanyName?: null;
229 | shippingAddressAddress1: string;
230 | shippingAddressAddress2: string;
231 | shippingAddressCity: string;
232 | shippingAddressCountry: string;
233 | shippingAddressProvince: string;
234 | shippingAddressPostalCode: string;
235 | shippingAddressPhone?: null;
236 | totalNumberOfItems: number;
237 | invoiceNumber: string;
238 | billingAddressComplete: boolean;
239 | shippingAddressComplete: boolean;
240 | shippingMethodComplete: boolean;
241 | savedAmount: number;
242 | subtotal: number;
243 | baseTotal: number;
244 | itemsTotal: number;
245 | totalPriceWithoutDiscountsAndTaxes: number;
246 | taxableTotal: number;
247 | grandTotal: number;
248 | total: number;
249 | totalWeight: number;
250 | totalRebateRate: number;
251 | customFields?: null[] | null;
252 | shippingEnabled: boolean;
253 | numberOfItemsInOrder: number;
254 | paymentTransactionId: string;
255 | metadata?: null;
256 | taxesTotal: number;
257 | itemsCount: number;
258 | summary: Summary;
259 | ipAddress: string;
260 | userAgent: string;
261 | hasSubscriptions: boolean;
262 | userDefinedId?: string;
263 | shippingRateUserDefinedId: string;
264 | }
265 | export interface BillingAddressOrShippingAddress {
266 | fullName: string;
267 | firstName?: null;
268 | name: string;
269 | company?: null;
270 | address1: string;
271 | address2: string;
272 | fullAddress: string;
273 | city: string;
274 | country: string;
275 | postalCode: string;
276 | province: string;
277 | phone?: null;
278 | vatNumber?: null;
279 | hasMinimalRequiredInfo: boolean;
280 | }
281 |
282 | export interface PaymentDetails {
283 | iconUrl?: null;
284 | display?: null;
285 | instructions?: null;
286 | }
287 | export interface Summary {
288 | subtotal: number;
289 | taxableTotal: number;
290 | total: number;
291 | payableNow: number;
292 | paymentMethod: string;
293 | taxes?: null[] | null;
294 | discountInducedTaxesVariation: number;
295 | adjustedTotal: number;
296 | shipping?: null;
297 | }
298 |
299 | export interface ChitChatsBatch {
300 | id: number;
301 | status: 'received' | 'ready' | 'pending';
302 | created_at: string;
303 | label_png_url: string;
304 | label_zpl_url: string;
305 | }
306 |
307 | export interface BatchRequest {
308 | batchId: string;
309 | shipmentIds: string[];
310 | }
311 |
--------------------------------------------------------------------------------
/app/interfaces/index.ts:
--------------------------------------------------------------------------------
1 | // You can include shared interfaces/types in a separate file
2 | // and then use them in any component by importing them. For
3 | // example, to import the interface below do:
4 | //
5 | // import { User } from 'path/to/interfaces';
6 |
7 | export type User = {
8 | id: number
9 | name: string
10 | }
11 |
--------------------------------------------------------------------------------
/app/interfaces/snipcart.d.ts:
--------------------------------------------------------------------------------
1 | import { ShippingRatesRequest } from './chitchat.d';
2 |
3 | export interface SnipCartOrdersResponse {
4 | items?: SnipCartOrder[];
5 | totalItems: number;
6 | offset: number;
7 | limit: number;
8 | }
9 |
10 | export interface SnipCartOrderResponse {
11 | items?: SnipCartOrder[];
12 | status?: null;
13 | paymentStatus?: null;
14 | campaignId?: null;
15 | invoiceNumber?: null;
16 | isRecurringOrder?: null;
17 | placedBy?: null;
18 | productId?: null;
19 | cascade: boolean;
20 | from?: null;
21 | to?: null;
22 | format?: null;
23 | totalItems: number;
24 | offset: number;
25 | limit: number;
26 | }
27 |
28 | export interface SnipCartOrder {
29 | id: string;
30 | discounts?: null[] | null;
31 | items?: SnipCartOrderItem[] | null;
32 | plans?: null[] | null;
33 | refunds?: null[] | null;
34 | taxes?: (TaxesEntity | null)[] | null;
35 | user: User;
36 | vnexT_MigrationFailed: boolean;
37 | token: string;
38 | isRecurringOrder: boolean;
39 | parentToken?: null;
40 | parentInvoiceNumber?: null;
41 | subscriptionId?: null;
42 | currency: string;
43 | creationDate: string;
44 | modificationDate: string;
45 | recoveredFromCampaignId?: null;
46 | status: string;
47 | paymentStatus: string;
48 | email: string;
49 | willBePaidLater: boolean;
50 | billingAddress: BillingAddressOrShippingAddress;
51 | shippingAddress: BillingAddressOrShippingAddress;
52 | shippingAddressSameAsBilling: boolean;
53 | creditCardLast4Digits: string;
54 | trackingNumber?: null;
55 | trackingUrl?: null;
56 | shippingFees: number;
57 | shippingProvider?: null;
58 | shippingMethod: string;
59 | cardHolderName?: null;
60 | paymentMethod: string;
61 | notes?: null;
62 | customFieldsJson: string;
63 | customFields: SnipCartCustomField[];
64 | userId: string;
65 | completionDate: string;
66 | cardType: string;
67 | paymentGatewayUsed: string;
68 | paymentDetails: PaymentDetails;
69 | taxProvider: string;
70 | lang: string;
71 | refundsAmount: number;
72 | adjustedAmount: number;
73 | finalGrandTotal: number;
74 | billingAddressFirstName?: null;
75 | billingAddressName: string;
76 | billingAddressCompanyName?: null;
77 | billingAddressAddress1: string;
78 | billingAddressAddress2: string;
79 | billingAddressCity: string;
80 | billingAddressCountry: string;
81 | billingAddressProvince: string;
82 | billingAddressPostalCode: string;
83 | billingAddressPhone?: null;
84 | shippingAddressFirstName?: null;
85 | shippingAddressName: string;
86 | shippingAddressCompanyName?: null;
87 | shippingAddressAddress1: string;
88 | shippingAddressAddress2: string;
89 | shippingAddressCity: string;
90 | shippingAddressCountry: string;
91 | shippingAddressProvince: string;
92 | shippingAddressPostalCode: string;
93 | shippingAddressPhone?: null;
94 | totalNumberOfItems: number;
95 | invoiceNumber: string;
96 | billingAddressComplete: boolean;
97 | shippingAddressComplete: boolean;
98 | shippingMethodComplete: boolean;
99 | savedAmount: number;
100 | subtotal: number;
101 | baseTotal: number;
102 | itemsTotal: number;
103 | totalPriceWithoutDiscountsAndTaxes: number;
104 | taxableTotal: number;
105 | grandTotal: number;
106 | total: number;
107 | totalWeight: number;
108 | totalRebateRate: number;
109 | customFields?: SnipCartCustomField[];
110 | shippingEnabled: boolean;
111 | numberOfItemsInOrder: number;
112 | paymentTransactionId: string;
113 | metadata?: MetaData;
114 | taxesTotal: number;
115 | itemsCount: number;
116 | summary: Summary;
117 | ipAddress: string;
118 | userAgent: string;
119 | hasSubscriptions: boolean;
120 | }
121 | export interface SnipCartOrderItem {
122 | paymentSchedule: PaymentSchedule;
123 | pausingAction: string;
124 | cancellationAction: string;
125 | token: string;
126 | name: string;
127 | price: number;
128 | quantity: number;
129 | fileGuid?: null;
130 | url: string;
131 | id: string;
132 | initialData: string;
133 | description: string;
134 | categories?: null[] | null;
135 | totalPriceWithoutTaxes: number;
136 | weight: number;
137 | image: string;
138 | originalPrice?: null;
139 | uniqueId: string;
140 | stackable: boolean;
141 | minQuantity?: null;
142 | maxQuantity: number;
143 | addedOn: string;
144 | modificationDate: string;
145 | shippable: boolean;
146 | taxable: boolean;
147 | duplicatable: boolean;
148 | width: number;
149 | height: number;
150 | length: number;
151 | metadata?: MetaData;
152 | __VNEXT_OrderId: number;
153 | totalPrice: number;
154 | totalWeight: number;
155 | taxes?: null[] | null;
156 | alternatePrices: AlternatePricesOrValidationErrors;
157 | customFields?: SnipCartCustomField[];
158 | unitPrice: number;
159 | hasDimensions: boolean;
160 | hasTaxesIncluded: boolean;
161 | totalPriceWithoutDiscountsAndTaxes: number;
162 | }
163 |
164 | export interface SnipCartCustomField {
165 | name: string;
166 | displayValue: string;
167 | value: string;
168 | }
169 | export interface PaymentSchedule {
170 | interval: number;
171 | intervalCount: number;
172 | trialPeriodInDays?: null;
173 | startsOn: string;
174 | }
175 | export interface AlternatePricesOrValidationErrors {}
176 | export interface TaxesEntity {
177 | __VNEXT_OrderId: number;
178 | taxName: string;
179 | taxRate: number;
180 | amount: number;
181 | numberForInvoice: string;
182 | includedInPrice: boolean;
183 | appliesOnShipping: boolean;
184 | discountInducedAmountVariation: number;
185 | }
186 | export interface User {
187 | id: string;
188 | email: string;
189 | mode: string;
190 | statistics: Statistics;
191 | creationDate: string;
192 | billingAddressFirstName?: null;
193 | billingAddressName: string;
194 | billingAddressCompanyName?: null;
195 | billingAddressAddress1: string;
196 | billingAddressAddress2: string;
197 | billingAddressCity: string;
198 | billingAddressCountry: string;
199 | billingAddressProvince: string;
200 | billingAddressPostalCode: string;
201 | billingAddressPhone?: null;
202 | shippingAddressFirstName?: null;
203 | shippingAddressName: string;
204 | shippingAddressCompanyName?: null;
205 | shippingAddressAddress1: string;
206 | shippingAddressAddress2: string;
207 | shippingAddressCity: string;
208 | shippingAddressCountry: string;
209 | shippingAddressProvince: string;
210 | shippingAddressPostalCode: string;
211 | shippingAddressPhone?: null;
212 | shippingAddressSameAsBilling: boolean;
213 | status: string;
214 | sessionToken?: null;
215 | gravatarUrl: string;
216 | billingAddress: BillingAddressOrShippingAddress;
217 | shippingAddress: BillingAddressOrShippingAddress;
218 | }
219 | export interface Statistics {
220 | ordersCount: number;
221 | ordersAmount?: null;
222 | subscriptionsCount: number;
223 | }
224 | export interface BillingAddressOrShippingAddress {
225 | fullName: string;
226 | firstName?: null;
227 | name: string;
228 | company?: null;
229 | address1: string;
230 | address2: string;
231 | fullAddress: string;
232 | city: string;
233 | country: string;
234 | postalCode: string;
235 | province: string;
236 | phone?: null;
237 | vatNumber?: null;
238 | hasMinimalRequiredInfo: boolean;
239 | validationErrors: AlternatePricesOrValidationErrors;
240 | }
241 | export interface PaymentDetails {
242 | iconUrl?: null;
243 | display?: null;
244 | instructions?: null;
245 | }
246 | export interface Summary {
247 | subtotal: number;
248 | taxableTotal: number;
249 | total: number;
250 | payableNow: number;
251 | paymentMethod: string;
252 | taxes?: (TaxesEntity1 | null)[] | null;
253 | discountInducedTaxesVariation: number;
254 | adjustedTotal: number;
255 | shipping?: null;
256 | }
257 | export interface TaxesEntity1 {
258 | taxId?: null;
259 | name: string;
260 | rate: number;
261 | amount: number;
262 | unroundedAmount: number;
263 | numberForInvoice: string;
264 | includedInPrice: boolean;
265 | appliesOnShipping: boolean;
266 | discountInducedAmountVariation: number;
267 | }
268 |
269 | export interface SnipcartShipmentRate {
270 | cost: number;
271 | description?: string;
272 | guaranteedDaysToDelivery?: number;
273 | additionalInfos?: string;
274 | shippingProvider?: string;
275 | }
276 |
277 | export interface SnipcartRequestParams {
278 | offset?: number;
279 | limit?: number;
280 | status?:
281 | | 'InProgress'
282 | | 'Processed'
283 | | 'Disputed'
284 | | 'Shipped'
285 | | 'Delivered'
286 | | 'Pending'
287 | | 'Cancelled';
288 | invoiceNumber?: string;
289 | productId?: string;
290 | placedBy?: string;
291 | from?: string;
292 | to?: string;
293 | isRecurringOrder?: boolean;
294 | }
295 |
296 | interface MetaData {
297 | [key: string]: any;
298 | }
299 |
300 | export interface SnipCartDimensions {
301 | weight: number;
302 | width: number;
303 | length: number;
304 | height: number;
305 | }
306 |
307 | export interface SnipCartProductDefinition {
308 | id: string;
309 | name: string;
310 | price: number;
311 | url: string;
312 | description: string;
313 | image?: string;
314 | categories?: any;
315 | metadata?: MetaData;
316 | fileGuid?: string;
317 | quantity?: number;
318 | minQuantity?: number;
319 | maxQuantity?: number;
320 | quantityStep?: number;
321 | dimensions?: SnipCartDimensions;
322 | customFields?: MetaData[];
323 | stackable?: 'always' | 'auto' | 'never';
324 | shippable?: boolean;
325 | hasTaxesIncluded?: boolean;
326 | taxable?: boolean;
327 | }
328 |
329 | // Shipping Request Types
330 | export interface SnipCartShippingRequest extends ShippingRatesRequest {
331 | eventName: string;
332 | mode: string;
333 | createdOn: string;
334 | content: Content;
335 | }
336 | export interface Content {
337 | shippingRateUserDefinedId: string;
338 | token: string;
339 | creationDate: string;
340 | modificationDate: string;
341 | status: string;
342 | currency: string;
343 | lang: string;
344 | paymentMethod: string;
345 | email: string;
346 | cardHolderName: string;
347 | billingAddressName: string;
348 | billingAddressCompanyName: string;
349 | billingAddressAddress1: string;
350 | billingAddressAddress2: string;
351 | billingAddressCity: string;
352 | billingAddressCountry: string;
353 | billingAddressProvince: string;
354 | billingAddressPostalCode: string;
355 | billingAddressPhone: string;
356 | shippingAddressName: string;
357 | shippingAddress: MetaData;
358 | shippingAddressCompanyName: string;
359 | shippingAddressAddress1: string;
360 | shippingAddressAddress2: string;
361 | shippingAddressCity: string;
362 | shippingAddressCountry: string;
363 | shippingAddressProvince: string;
364 | shippingAddressPostalCode: string;
365 | shippingAddressPhone: string;
366 | shippingAddressSameAsBilling: boolean;
367 | finalGrandTotal: number;
368 | shippingAddressComplete: boolean;
369 | creditCardLast4Digits: string;
370 | shippingFees: number;
371 | shippingMethod: string;
372 | items: ItemsEntity[];
373 | subtotal: number;
374 | totalWeight: number;
375 | discounts?: null[] | null;
376 | willBePaidLater: boolean;
377 | }
378 | export interface ItemsEntity {
379 | uniqueId: string;
380 | token: string;
381 | id: string;
382 | name: string;
383 | price: number;
384 | originalPrice: number;
385 | quantity: number;
386 | url: string;
387 | weight: number;
388 | description: string;
389 | image: string;
390 | customFieldsJson: string;
391 | customFields: SnipCartCustomField[];
392 | stackable: boolean;
393 | maxQuantity?: null;
394 | totalPrice: number;
395 | totalWeight: number;
396 | width: number;
397 | height: number;
398 | length: number;
399 | shippable: boolean;
400 | }
401 |
--------------------------------------------------------------------------------
/app/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "swag-fulfilment",
3 | "version": "1.0.1",
4 | "scripts": {
5 | "dev": "next --port 7777",
6 | "ngrok": "ngrok http -subdomain=wesbosswagx 7777",
7 | "all": "npm-run-all tunnel",
8 | "build": "next build",
9 | "start": "next start --port 7777",
10 | "type-check": "tsc"
11 | },
12 | "eslintConfig": {
13 | "extends": [
14 | "wesbos/typescript"
15 | ],
16 | "settings": {
17 | "import/resolver": {
18 | "typescript": {}
19 | }
20 | },
21 | "rules": {
22 | "no-use-before-define": 0,
23 | "react/jsx-props-no-spreading": 0,
24 | "@typescript-eslint/no-explicit-any": 0
25 | }
26 | },
27 | "dependencies": {
28 | "@types/http-proxy": "^1.17.5",
29 | "cors": "^2.8.5",
30 | "date-fns": "^2.17.0",
31 | "dotenv": "^8.2.0",
32 | "emoji-flags": "^1.3.0",
33 | "http-proxy": "^1.18.1",
34 | "isomorphic-fetch": "^3.0.0",
35 | "next": "latest",
36 | "next-auth": "^3.4.1",
37 | "normalize.css": "^8.0.1",
38 | "npm-run-all": "^4.1.5",
39 | "nprogress": "^0.2.0",
40 | "qrcode.react": "^1.0.1",
41 | "react": "^17.0.1",
42 | "react-dom": "^17.0.1",
43 | "react-loadable": "^5.5.0",
44 | "react-query": "^3.9.6",
45 | "react-table": "^7.6.3",
46 | "styled-components": "^5.2.1",
47 | "waait": "^1.0.5"
48 | },
49 | "devDependencies": {
50 | "@types/cors": "^2.8.10",
51 | "@types/isomorphic-fetch": "^0.0.35",
52 | "@types/next-auth": "^3.1.24",
53 | "@types/node": "^14.14.28",
54 | "@types/nprogress": "^0.2.0",
55 | "@types/qrcode.react": "^1.0.1",
56 | "@types/react": "^17.0.2",
57 | "@types/react-dom": "^17.0.1",
58 | "@types/styled-components": "^5.1.7",
59 | "@typescript-eslint/eslint-plugin": "^4.15.1",
60 | "@typescript-eslint/parser": "^4.15.1",
61 | "babel-eslint": "^10.1.0",
62 | "babel-plugin-styled-components": "^1.12.0",
63 | "eslint": "^7.20.0",
64 | "eslint-config-airbnb": "^18.2.1",
65 | "eslint-config-airbnb-typescript": "^12.3.1",
66 | "eslint-config-prettier": "^7.2.0",
67 | "eslint-config-wesbos": "^2.0.0-beta.3",
68 | "eslint-import-resolver-typescript": "^2.3.0",
69 | "eslint-plugin-html": "^6.1.1",
70 | "eslint-plugin-import": "^2.22.1",
71 | "eslint-plugin-jsx-a11y": "^6.4.1",
72 | "eslint-plugin-prettier": "^3.3.1",
73 | "eslint-plugin-react": "^7.22.0",
74 | "eslint-plugin-react-hooks": "^4.2.0",
75 | "prettier": "^2.2.1",
76 | "typescript": "^4.1.5"
77 | },
78 | "license": "MIT",
79 | "engines": {
80 | "node": "14.x"
81 | },
82 | "babel": {
83 | "presets": [
84 | "next/babel"
85 | ],
86 | "plugins": [
87 | [
88 | "styled-components",
89 | {
90 | "ssr": true
91 | }
92 | ]
93 | ]
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/app/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import { QueryClient, QueryClientProvider } from 'react-query';
2 | import type { AppProps } from 'next/app';
3 | import 'nprogress/nprogress.css';
4 | import { Router } from 'next/dist/client/router';
5 | import nProgress from 'nprogress';
6 | import { Provider } from 'next-auth/client';
7 | import { ReactQueryDevtools } from 'react-query/devtools';
8 |
9 | Router.events.on('routeChangeStart', nProgress.start);
10 | Router.events.on('routeChangeComplete', nProgress.done);
11 | Router.events.on('routeChangeError', nProgress.done);
12 |
13 | // important: create the query client outside the _app component
14 |
15 | const queryClient = new QueryClient();
16 |
17 | function MyApp({ Component, pageProps }: AppProps) {
18 | return (
19 |
20 |
21 | <>
22 |
23 |
24 | >
25 |
26 |
27 | );
28 | }
29 |
30 | export default MyApp;
31 |
--------------------------------------------------------------------------------
/app/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import Document, { DocumentContext } from 'next/document';
2 | import { ServerStyleSheet } from 'styled-components';
3 |
4 | export default class MyDocument extends Document {
5 | static async getInitialProps(ctx: DocumentContext) {
6 | const sheet = new ServerStyleSheet();
7 | const originalRenderPage = ctx.renderPage;
8 |
9 | try {
10 | ctx.renderPage = () =>
11 | originalRenderPage({
12 | enhanceApp: (App) => (props) =>
13 | sheet.collectStyles(),
14 | });
15 |
16 | const initialProps = await Document.getInitialProps(ctx);
17 | return {
18 | ...initialProps,
19 | styles: (
20 | <>
21 | {initialProps.styles}
22 | {sheet.getStyleElement()}
23 | >
24 | ),
25 | };
26 | } finally {
27 | sheet.seal();
28 | }
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/app/pages/api/auth/[...nextauth].ts:
--------------------------------------------------------------------------------
1 | import NextAuth from 'next-auth';
2 | import Providers from 'next-auth/providers';
3 | // @ts-ignore
4 | export default NextAuth(
5 | // @ts-ignore Types are coming https://github.com/nextauthjs/next-auth/pull/1223/files
6 | {
7 | providers: [
8 | Providers.GitHub({
9 | clientId: process.env.GITHUB_CLIENT_ID || '',
10 | clientSecret: process.env.GITHUB_SECRET || '',
11 | }),
12 | ],
13 | jwt: {
14 | signingKey: process.env.JWT_SIGNING_PRIVATE_KEY,
15 | },
16 | }
17 | );
18 |
--------------------------------------------------------------------------------
/app/pages/api/batches/[id].ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next';
2 | import { getBatch } from '../../../utils/chitchats';
3 | import { withAuth } from '../../../utils/withAuth';
4 |
5 | async function handler(req: NextApiRequest, res: NextApiResponse) {
6 | if (!req.query.id) {
7 | res.status(200).json({});
8 | return;
9 | }
10 | const id = Array.isArray(req.query.id) ? req.query.id[0] : req.query.id;
11 | const batch = await getBatch(id);
12 | res.status(200).json(batch.data);
13 | }
14 |
15 | export default withAuth(handler);
16 |
--------------------------------------------------------------------------------
/app/pages/api/batches/index.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next';
2 | import { createBatch, getBatches } from '../../../utils/chitchats';
3 | import { withAuth } from '../../../utils/withAuth';
4 |
5 | async function handler(req: NextApiRequest, res: NextApiResponse) {
6 | if (req.method === 'GET') {
7 | const batches = await getBatches();
8 | res.status(200).json(batches.data);
9 | return;
10 | }
11 |
12 | if (req.method === 'POST') {
13 | const batch = await createBatch();
14 | console.log(batch);
15 | res.status(200).json(batch);
16 | }
17 | }
18 |
19 | export default withAuth(handler);
20 |
--------------------------------------------------------------------------------
/app/pages/api/orders/[token].ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next';
2 | import { getOrder, updateOrder } from '../../../utils/snipCartAPI';
3 | import { withAuth } from '../../../utils/withAuth';
4 |
5 | async function handler(req: NextApiRequest, res: NextApiResponse) {
6 | // Update Order. Mark as shipped
7 | const token = req.query.token as string;
8 | if (req.method === 'POST') {
9 | const order = await updateOrder(token, {
10 | // This should probably be sent from the query
11 | status: 'Shipped',
12 | });
13 |
14 | res.status(200).json(order);
15 | return;
16 | }
17 | // READ
18 | if (req.method === 'GET') {
19 | if (!req.query?.token) {
20 | throw new Error('You must specify an order token');
21 | }
22 | const order = await getOrder(token);
23 | res.status(200).json(order);
24 | }
25 | }
26 |
27 | export default withAuth(handler);
28 |
--------------------------------------------------------------------------------
/app/pages/api/orders/index.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next';
2 | import getOrders from '../../../utils/snipCartAPI';
3 | import { withAuth } from '../../../utils/withAuth';
4 |
5 | async function handler(req: NextApiRequest, res: NextApiResponse) {
6 | const orders = await getOrders(req.query);
7 | res.status(200).json(orders);
8 | }
9 |
10 | export default withAuth(handler);
11 |
--------------------------------------------------------------------------------
/app/pages/api/orders/refetch-metadata.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next';
2 | import { getShipment } from '../../../utils/chitchats';
3 | import { getOrder, updateOrder } from '../../../utils/snipCartAPI';
4 | import { withAuth } from '../../../utils/withAuth';
5 |
6 | async function handler(
7 | req: NextApiRequest,
8 | res: NextApiResponse
9 | ): Promise {
10 | // This function:
11 | // 1. Takes in a SnipCart Token
12 | // 2. Find's the Snipcart Order
13 | // 3. From the order's metadata, finds the chitchat ID
14 | // 4. Looks up the Chit Chat Shipment to get the label URL
15 | // 5. Saves the Label URL back to SnipCart
16 | const { token: inboundToken } = req.query;
17 |
18 | const token = Array.isArray(inboundToken) ? inboundToken[0] : inboundToken;
19 |
20 | // 1. Lookup snipcart Order
21 | const order = await getOrder(token);
22 | if (!order || !order.metadata) {
23 | return res.status(404).json({ message: 'No metadata for this order' });
24 | }
25 | const { chitChatId } = order.metadata;
26 |
27 | if (!chitChatId) {
28 | return res
29 | .status(404)
30 | .json({ message: 'No Chit Chats ID found for this order' });
31 | }
32 | // 2. find Chit Chat Shipment
33 | const shipmentResponse = await getShipment(chitChatId);
34 | const shipmentData = shipmentResponse.data?.shipment;
35 |
36 | // 3. Update the metadata in SnipCart
37 | const updatedOrder = await updateOrder(token, {
38 | metadata: {
39 | label: shipmentData?.postage_label_png_url,
40 | labelZpl: shipmentData?.postage_label_zpl_url,
41 | chitChatId,
42 | },
43 | trackingNumber: shipmentData?.carrier_tracking_code,
44 | trackingUrl: shipmentData?.tracking_url,
45 | });
46 | res.status(200).json(updatedOrder);
47 | }
48 |
49 | export default withAuth(handler);
50 |
--------------------------------------------------------------------------------
/app/pages/api/products/index.ts:
--------------------------------------------------------------------------------
1 | import Cors from 'cors';
2 | import { NextApiResponse, NextApiRequest } from 'next';
3 | import initMiddleware from '../../../utils/initMiddleware';
4 | import { getProducts } from '../../../utils/snipCartAPI';
5 |
6 | const cors = initMiddleware(
7 | Cors({
8 | methods: ['GET'],
9 | origin: '*',
10 | })
11 | );
12 |
13 | async function handler(
14 | req: NextApiRequest,
15 | res: NextApiResponse
16 | ): Promise {
17 | await cors(req, res);
18 | const products = await getProducts();
19 | res.status(200).json(products);
20 | }
21 |
22 | export default handler;
23 |
--------------------------------------------------------------------------------
/app/pages/api/shipments/[id].ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next';
2 | import { getShipment } from '../../../utils/chitchats';
3 | import { withAuth } from '../../../utils/withAuth';
4 |
5 | async function handler(req: NextApiRequest, res: NextApiResponse) {
6 | const shipment = await getShipment(req.query.id as string);
7 | res.status(200).json(shipment.data);
8 | }
9 |
10 | export default withAuth(handler);
11 |
--------------------------------------------------------------------------------
/app/pages/api/shipments/add_to_batch.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next';
2 | import { addToBatch } from '../../../utils/chitchats';
3 | import { withAuth } from '../../../utils/withAuth';
4 |
5 | async function handler(req: NextApiRequest, res: NextApiResponse) {
6 | console.log(req.body);
7 | const body = JSON.parse(req.body);
8 | await addToBatch(req.body);
9 | res.status(200).json({
10 | message: `Added ${body.shipment_ids} to Batch ${body.batch_id}`,
11 | });
12 | }
13 |
14 | export default withAuth(handler);
15 |
--------------------------------------------------------------------------------
/app/pages/api/shipments/index.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next';
2 | import { getShipments } from '../../../utils/chitchats';
3 | import { withAuth } from '../../../utils/withAuth';
4 |
5 | async function handler(req: NextApiRequest, res: NextApiResponse) {
6 | if (req.method === 'GET') {
7 | const params = new URLSearchParams(req.query as any);
8 | const shipments = await getShipments({
9 | params: `?${params.toString()}`,
10 | });
11 | res.status(200).json(shipments.data);
12 | }
13 | }
14 |
15 | export default withAuth(handler);
16 |
--------------------------------------------------------------------------------
/app/pages/api/shipping/index.ts:
--------------------------------------------------------------------------------
1 | import { NextApiResponse, NextApiRequest } from 'next';
2 | import { SnipCartShippingRequest } from '../../../interfaces/snipcart.d';
3 | import { getShippingQuotes } from '../../../utils/getShippingQuote';
4 | import { getShipment } from '../../../utils/chitchats';
5 | import { convertChitChatRatesToSnipCart } from '../../../utils/snipCart';
6 |
7 | export default async function handler(
8 | req: NextApiRequest,
9 | res: NextApiResponse
10 | ): Promise {
11 | console.log('Shipping API Webhook - Requesting Rates');
12 |
13 | // First check if we have already quoted this one. We do this because Snipcart requests the shipping rates once from the client, and then again from their confirmation server to ensure no one monkied with the values.
14 | const shippingRatesRequest = req.body as SnipCartShippingRequest;
15 |
16 | let rateResponse;
17 | if (shippingRatesRequest.content.shippingMethod) {
18 | console.log(
19 | 'There is an existing shipping method, lets check for a shipping ID'
20 | );
21 |
22 | const [
23 | shippingId,
24 | ] = shippingRatesRequest.content.shippingRateUserDefinedId.split(' --- ');
25 |
26 | if (shippingId) {
27 | console.log(`There is an existing shipping ID!! ${shippingId}`);
28 | const shipmentResponse = await getShipment(shippingId);
29 | // TODO, parse this into a rate response
30 | rateResponse = {
31 | rates: convertChitChatRatesToSnipCart(shipmentResponse),
32 | };
33 | console.log('Cached Shipping rates:');
34 | console.log(rateResponse);
35 | } else {
36 | rateResponse = await getShippingQuotes(shippingRatesRequest);
37 | }
38 | } else {
39 | // Get fresh quotes
40 | console.log('Fresh Rates');
41 | rateResponse = await getShippingQuotes(shippingRatesRequest);
42 | }
43 | console.log(rateResponse);
44 | res.status(200).json(rateResponse);
45 | }
46 |
--------------------------------------------------------------------------------
/app/pages/api/snipcart/[...proxy].ts:
--------------------------------------------------------------------------------
1 | import { NextApiResponse, NextApiRequest } from 'next';
2 | import fetch from 'isomorphic-fetch';
3 | import { endpoint, headers } from '../../../utils/snipCartAPI';
4 | import { withAuth } from '../../../utils/withAuth';
5 |
6 | function handleError(err) {
7 | console.log('Error!', err);
8 | }
9 |
10 | async function handler(
11 | req: NextApiRequest,
12 | res: NextApiResponse
13 | ): Promise {
14 | console.log('Proxying Snipcart API');
15 | const url = req.url?.replace('/api/snipcart/', '');
16 | console.log(url);
17 | console.log(req.body);
18 | if (!url) {
19 | return res.status(500).json({ message: 'No URL Provided' });
20 | }
21 |
22 | const response = await fetch(`${endpoint}/${url}`, {
23 | headers,
24 | method: req.method,
25 | body: req.method === 'GET' ? undefined : (req.body as string),
26 | }).catch(handleError);
27 |
28 | const data: any = await response.json().catch(handleError);
29 | // res.status(200).json({ url, query, method: req.method, body: req.body });
30 | res.status(response.status).json(data);
31 | }
32 |
33 | export default withAuth(handler);
34 |
--------------------------------------------------------------------------------
/app/pages/api/test/index.ts:
--------------------------------------------------------------------------------
1 | import { NextApiResponse, NextApiRequest } from 'next';
2 |
3 | async function handler(
4 | // @ts-ignore
5 | req: NextApiRequest,
6 | res: NextApiResponse
7 | ): Promise {
8 | res.status(200).json({
9 | CHITHCHATS_CLIENT_ID: process.env.CHITCHATS_CLIENT_ID,
10 | VERCEL_ENV: process.env.VERCEL_ENV,
11 | });
12 | }
13 |
14 | export default handler;
15 |
--------------------------------------------------------------------------------
/app/pages/api/webhook/index.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next';
2 | import waait from 'waait';
3 | import { Content } from '../../../interfaces/chitchat';
4 | import { buyShipment, getShipment } from '../../../utils/chitchats';
5 | import { updateOrder } from '../../../utils/snipCartAPI';
6 |
7 | interface SnipCartWebhookBody {
8 | eventName: string;
9 | content: Content;
10 | }
11 |
12 | export default async function handler(
13 | req: NextApiRequest,
14 | res: NextApiResponse
15 | ): Promise {
16 | console.group('Webhook Request');
17 | // Todo: Auth Snipcart webhook with X-Snipcart-RequestToken Header
18 | // https://app.snipcart.com/api/requestvalidation/{token}
19 |
20 | const body = req.body as SnipCartWebhookBody;
21 | console.log(`Incoming Webhook: ${body.eventName}`);
22 | // console.log(req);
23 | if (body.eventName === 'order.completed') {
24 | // Right now we're just getting quotes. buying shipment once we are ready to ship
25 | return res.status(200).json({ nothing: 'Not currently Buying Shipment' });
26 | console.log('buying Shipment!');
27 | const { content } = body;
28 | const [
29 | shippingId,
30 | shippingMethod,
31 | ] = content.shippingRateUserDefinedId.split(' --- ');
32 |
33 | const shipmentResponse = await buyShipment(shippingId, shippingMethod);
34 | console.dir(shipmentResponse, { depth: null });
35 | console.log('Now waiting 3 seconds...');
36 | // Wait ~3 seconds
37 | await waait(3000);
38 | // Fetch the shipment Info
39 | console.log('fetching shipment');
40 | const { data } = await getShipment(shippingId);
41 | const shipment = data?.shipment;
42 | console.log('Updating Shipment data in Snipcart');
43 | const updatedOrder = await updateOrder(body.content.token, {
44 | trackingNumber: shipment?.carrier_tracking_code,
45 | trackingUrl: shipment?.tracking_url,
46 | // don't mark as shipped just yet
47 | // status: 'Shipped',
48 | metadata: {
49 | label: shipment?.postage_label_png_url,
50 | labelZpl: shipment?.postage_label_zpl_url,
51 | chitChatId: shippingId,
52 | },
53 | });
54 | console.log('Done!', updatedOrder);
55 |
56 | console.groupEnd();
57 | return res.status(200).json(updatedOrder);
58 | }
59 | return res.status(200).json({ nothing: 'To send here ' });
60 | }
61 | // 1. Buy the shipment while updating postage_type to be that of the order
62 | // 2. Wait 3 seconds? Maybe
63 | // 3. Check
64 |
--------------------------------------------------------------------------------
/app/pages/auth/index.tsx:
--------------------------------------------------------------------------------
1 | import { providers, signIn } from 'next-auth/client';
2 | import { Providers } from 'next-auth/providers';
3 |
4 | type SignInProps = {
5 | providers: Providers;
6 | };
7 |
8 | export default function SignIn({ providers: availableProviders }: SignInProps) {
9 | return (
10 |
11 |
hey
12 | {Object.values(availableProviders).map((provider) => (
13 |
14 |
17 |
18 | ))}
19 |
20 | );
21 | }
22 |
23 | SignIn.getInitialProps = async () => ({
24 | providers: await providers(),
25 | });
26 |
--------------------------------------------------------------------------------
/app/pages/batches/[batchId].tsx:
--------------------------------------------------------------------------------
1 | import { useRouter } from 'next/dist/client/router';
2 | import QRCode from 'qrcode.react';
3 | import { useState } from 'react';
4 | import { useQuery } from 'react-query';
5 | import GenericTable from '../../components/GenericTable';
6 | import { LabelStyles } from '../../components/Labels';
7 | import Layout from '../../components/Layout';
8 | import useBatch from '../../hooks/useBatch';
9 |
10 | export default function OrdersPage() {
11 | const { query } = useRouter();
12 | const { batchId } = query;
13 | const { isLoading, data = {} } = useQuery(['batch', batchId], () => {
14 | if (!batchId) return; // wait for router..
15 | return fetch(`/api/batches/${batchId}`).then((res) => res.json());
16 | });
17 | const { shipments } = useBatch(batchId);
18 | const { batch = {} } = data;
19 | const [labelShow, setLabelShow] = useState('batch-scanner');
20 | return (
21 |
22 |
58 | {isLoading && Loading...
}
59 | {labelShow === 'batch-scanner' && (
60 |
61 |
62 | Chit Chats Client Batch:{batchId}
63 |
64 | )}
65 | {labelShow === 'batch-finished' && (
66 |
67 | {batch.label_png_url ? (
68 |
69 | ) : (
70 | 'Label not yet created. You probably have to add items to this batch first'
71 | )}
72 |
73 | )}
74 |
75 | );
76 | }
77 |
--------------------------------------------------------------------------------
/app/pages/batches/index.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import { useMutation, useQuery } from 'react-query';
3 | import { formatDistanceToNow } from 'date-fns';
4 | import Layout from '../../components/Layout';
5 | import { ChitChatBatch } from '../../utils/chitchats';
6 |
7 | export default function OrdersPage() {
8 | const { isLoading, data: batches = [], refetch } = useQuery(
9 | 'batches',
10 | () => fetch('/api/batches').then((res) => res.json())
11 | );
12 | const createBatchMutation = useMutation(() =>
13 | fetch(`/api/batches`, {
14 | method: 'POST',
15 | })
16 | );
17 |
18 | return (
19 |
20 |
21 | Batches {batches?.length}
22 |
23 | {isLoading && Loading...
}
24 |
25 |
35 |
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/app/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import { useQuery } from 'react-query';
2 | import Layout from '../components/Layout';
3 | import { OrderTable } from '../components/OrderTable';
4 |
5 | export default function OrdersPage() {
6 | const { isLoading, data: orders } = useQuery('orders', () =>
7 | fetch('/api/orders?limit=200&status=Shipped').then((res) => res.json())
8 | );
9 |
10 | return (
11 |
12 | {/* */}
13 |
20 | {isLoading && Loading...
}
21 |
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/app/pages/orders.tsx:
--------------------------------------------------------------------------------
1 | import { useQuery } from 'react-query';
2 | import Layout from '../components/Layout';
3 | import { Labels } from '../components/Labels';
4 | import { SnipCartOrder } from '../interfaces/snipcart';
5 |
6 | export default function OrdersPage() {
7 | const { isLoading, data: orders } = useQuery('orders', () =>
8 | fetch('/api/orders?limit=100&status=Processed').then((res) => res.json())
9 | );
10 |
11 | return (
12 |
13 |
20 | {isLoading && Loading...
}
21 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/app/public/beep.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/wesbos/chit-chats-snipcart-integration/df4cb6ff1b4b37753ca2d9d0fc6bb7e07bfbfbab/app/public/beep.wav
--------------------------------------------------------------------------------
/app/readme.md:
--------------------------------------------------------------------------------
1 | ## Chit Chat x Snip Cart Order Fulfillment
2 |
3 | The workflow looks like this:
4 |
5 | ## Order Fulfillment Process:
6 | 1. For each order, we print 2 stickers:
7 | 1. The Shipping Label from Chit Chats
8 | 2. The packing slip
9 |
10 | 1. Before packing a single order, set up your batch. A batch is a box or bag of multiple orders that are dropped off at the shipper together. Either scan the code of an existing batch, or create a new batch.
11 |
12 | 1. If creating a new batch, print off the QR code for that batch and stick it to the batch box.
13 |
14 | 1. When we are ready to pack orders. We print off the Pending order labels.
15 | 1. An order needs to be assigned to a batch in Chit Chats, so scan the batch label to put you on the correct batch page.
16 | 1. Once you have packed an order, you must scan the order QR code, and this will do:
17 | 1. Mark the package as being in that specific batch (Chit Chats)
18 | 2. Mark the package as shipped in SnipCart. This will notify the customer that their order has shipped along with all tracking info.
19 |
--------------------------------------------------------------------------------
/app/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowJs": true,
4 | "alwaysStrict": true,
5 | "esModuleInterop": true,
6 | "forceConsistentCasingInFileNames": true,
7 | "types": [
8 | "node"
9 | ],
10 | "isolatedModules": true,
11 | "jsx": "preserve",
12 | "lib": [
13 | "dom",
14 | "es2017",
15 | "es2018",
16 | "es2019",
17 | ],
18 | "module": "esnext",
19 | "moduleResolution": "node",
20 | "noEmit": true,
21 | "noFallthroughCasesInSwitch": true,
22 | "noUnusedLocals": true,
23 | "noUnusedParameters": true,
24 | "resolveJsonModule": true,
25 | "skipLibCheck": true,
26 | "strict": true,
27 | "target": "esnext"
28 | },
29 | "exclude": [
30 | "node_modules"
31 | ],
32 | "include": [
33 | "**/*.ts",
34 | "**/*.tsx"
35 | ]
36 | }
37 |
--------------------------------------------------------------------------------
/app/utils/chitchats.ts:
--------------------------------------------------------------------------------
1 | import fetch from 'isomorphic-fetch';
2 | import { config } from 'dotenv';
3 | import {
4 | BatchRequest,
5 | Shipment,
6 | CreateShipmentInput,
7 | } from '../interfaces/chitchat.d';
8 |
9 | import { MetaData } from '../interfaces/snipcart';
10 |
11 | config();
12 | const baseURL = process.env.CHITCHATS_URL;
13 |
14 | interface RequestOptions {
15 | endpoint: string;
16 | method?: 'GET' | 'POST' | 'PUT' | 'PATCH';
17 | clientId?: string;
18 | limit?: number;
19 | page?: number;
20 | json?: boolean;
21 | data?: BodyInit | MetaData;
22 | params?: string;
23 | }
24 |
25 | export type ErrorMessage = {
26 | data?: {
27 | error?: {
28 | message: string;
29 | };
30 | };
31 | };
32 |
33 | export type ChitChatResponse = {
34 | data?: Data;
35 | headers: Headers;
36 | };
37 |
38 | export type ShipmentResponse = {
39 | shipment: Shipment;
40 | };
41 |
42 | export type ChitChatBatch = {
43 | id: number;
44 | status: 'ready' | 'pending' | 'received';
45 | created_at: string;
46 | label_png_url: string;
47 | label_zpl_url: string;
48 | };
49 |
50 | export type ChitChatAddShipmentToBatchInput = {
51 | batch_id: string;
52 | shipment_ids: string[];
53 | };
54 |
55 | export async function request({
56 | endpoint,
57 | method = 'GET',
58 | clientId = process.env.CHITCHATS_CLIENT_ID || '',
59 | json = true,
60 | data = '',
61 | params = '',
62 | }: RequestOptions): Promise> {
63 | if (!clientId) {
64 | throw new Error('No Client ID Provided.');
65 | }
66 | const url = `${baseURL}/clients/${clientId}/${endpoint}${params}`;
67 | console.log(`Fetching ${url} via a ${method} request with data ${data}`);
68 | const body = typeof data === 'string' ? data : JSON.stringify(data);
69 | const response = await fetch(url, {
70 | headers: {
71 | 'Content-Type': 'application/json;',
72 | Authorization: process.env.CHITCHATS_API_KEY || '',
73 | },
74 | // This needs to be json.stringify for createShipment(), but an object for addToBatch
75 | body: method === 'GET' ? undefined : body,
76 | method,
77 | }).catch((err) => {
78 | console.log('----------');
79 | console.log(err);
80 | console.log('----------');
81 | });
82 | if (!response) {
83 | throw new Error('No response');
84 | }
85 | if (response.status >= 400 && response.status <= 500) {
86 | console.log(
87 | `Error! ${response.status} ${response.statusText} when trying to hit ${response.url}`
88 | );
89 |
90 | const { error } = await response.json();
91 | console.log(error);
92 | throw new Error(error);
93 | }
94 |
95 | let res;
96 | if (json) res = (await response.json()) as T;
97 | return { data: res, headers: response.headers };
98 | }
99 |
100 | interface ShipmentArgs {
101 | params?: string;
102 | }
103 |
104 | export async function getShipments({ params }: ShipmentArgs = {}): Promise<
105 | ChitChatResponse
106 | > {
107 | const shipments = await request({
108 | endpoint: 'shipments',
109 | params,
110 | });
111 | return shipments;
112 | }
113 |
114 | export async function getShipment(
115 | id: string
116 | ): Promise> {
117 | const shipment = await request({
118 | endpoint: `shipments/${id}`,
119 | });
120 | return shipment;
121 | }
122 |
123 | // // TODO: This just returns 200OK, need to modify request()
124 | // export async function refundShipment(id: string): Promise {
125 | // const shipment = await request({
126 | // endpoint: `shipments/${id}/refund`,
127 | // method: 'PATCH',
128 | // });
129 | // console.log(shipment);
130 | // return shipment;
131 | // }
132 |
133 | export async function createShipment(
134 | data: CreateShipmentInput
135 | ): Promise> {
136 | const shipment = await request({
137 | endpoint: 'shipments',
138 | method: 'POST',
139 | data,
140 | json: true,
141 | });
142 | return shipment;
143 | }
144 |
145 | export async function buyShipment(
146 | id: string,
147 | postage_type: string
148 | ): Promise> {
149 | const shipment = await request({
150 | endpoint: `shipments/${id}/buy`,
151 | method: 'PATCH',
152 | data: {
153 | postage_type,
154 | },
155 | json: false,
156 | });
157 | return shipment;
158 | }
159 |
160 | export async function getBatches(): Promise> {
161 | const batches = await request({
162 | endpoint: 'batches',
163 | method: 'GET',
164 | json: true,
165 | });
166 | return batches;
167 | }
168 |
169 | export async function getBatch(
170 | id: string
171 | ): Promise> {
172 | const batches = await request({
173 | endpoint: `batches/${id.toString()}`,
174 | method: 'GET',
175 | json: true,
176 | });
177 | return batches;
178 | }
179 |
180 | export async function createBatch(): Promise<
181 | ChitChatResponse
182 | > {
183 | const batch = await request({
184 | endpoint: 'batches',
185 | method: 'POST',
186 | json: false,
187 | });
188 | return batch;
189 | }
190 |
191 | export async function addToBatch(
192 | data: BatchRequest
193 | ): Promise> {
194 | console.log(data);
195 | const batch = await request({
196 | endpoint: 'shipments/add_to_batch',
197 | method: 'PATCH',
198 | json: false,
199 | data,
200 | });
201 | return batch;
202 | }
203 |
--------------------------------------------------------------------------------
/app/utils/getShippingQuote.ts:
--------------------------------------------------------------------------------
1 | import {
2 | SnipCartCustomField,
3 | ItemsEntity,
4 | SnipCartShippingRequest,
5 | SnipcartShipmentRate,
6 | } from '../interfaces/snipcart.d';
7 |
8 | import { createShipment } from './chitchats';
9 | import { convertChitChatRatesToSnipCart } from './snipCart';
10 |
11 | interface ShippingRateError {
12 | key: string;
13 | message: string;
14 | }
15 |
16 | interface ShippingQuotes {
17 | rates?: SnipcartShipmentRate[] | null;
18 | errors?: ShippingRateError[];
19 | }
20 |
21 | function parseCustomFields(customFields: SnipCartCustomField[]) {
22 | return customFields.map((field) => `${field.displayValue}`).join(' ');
23 | }
24 |
25 | function packageDescription(items: ItemsEntity[]) {
26 | return items
27 | .map(
28 | (item) =>
29 | `${item.quantity} ${item.name} - ${parseCustomFields(
30 | item.customFields
31 | )}`
32 | )
33 | .join(', ');
34 | }
35 |
36 | export async function getShippingQuotes(
37 | incomingOrder: SnipCartShippingRequest
38 | ): Promise {
39 | console.group('Shipping Request');
40 | // A shipping quote is just a create shipment call with postage_type: 'unknown',
41 | const order = incomingOrder;
42 | const { shippingAddress, items, subtotal } = order.content;
43 |
44 | const totalWeight = items?.reduce(
45 | (tally: number, item) => item.weight * item.quantity + tally,
46 | 0
47 | );
48 | const shipDate = new Date();
49 | // Ships tomorrow
50 | shipDate.setDate(shipDate.getDate() + 1);
51 | const [MM, DD, YYYY] = shipDate
52 | .toLocaleString('en-US', {
53 | year: 'numeric',
54 | month: '2-digit',
55 | day: '2-digit',
56 | })
57 | .split('/');
58 | const res = await createShipment({
59 | // The User Details
60 | name: shippingAddress.fullName,
61 | address_1: shippingAddress.address1,
62 | address_2: shippingAddress.address2,
63 | city: shippingAddress.city,
64 | province_code: shippingAddress.province,
65 | postal_code: shippingAddress.postalCode,
66 | phone: shippingAddress.phone || '',
67 | country_code: shippingAddress.country,
68 | // The Item Details
69 | description: packageDescription(items),
70 | value: `${subtotal}`,
71 | value_currency: 'usd',
72 | package_type: 'thick_envelope',
73 | package_contents: 'merchandise',
74 | size_unit: 'cm',
75 | size_x: items[0].width,
76 | size_y: items[0].height,
77 | size_z: items[0].length,
78 | weight_unit: 'g',
79 | weight: totalWeight,
80 | // The Most Important Parts
81 | ship_date: `${YYYY}-${MM}-${DD}`,
82 | // ship_date: 'today', // TODO: Make this flexible for tomorrow. The above should work
83 | postage_type: 'unknown',
84 | });
85 |
86 | const rates = convertChitChatRatesToSnipCart(res);
87 | console.groupEnd();
88 | return {
89 | rates,
90 | };
91 | }
92 |
--------------------------------------------------------------------------------
/app/utils/initMiddleware.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next';
2 | export default function initMiddleware(middleware: CallableFunction) {
3 | return (req: NextApiRequest, res: NextApiResponse) =>
4 | new Promise((resolve, reject) => {
5 | middleware(req, res, (result: any) => {
6 | if (result instanceof Error) {
7 | return reject(result)
8 | }
9 | return resolve(result)
10 | })
11 | })
12 | }
13 |
--------------------------------------------------------------------------------
/app/utils/snipCart.ts:
--------------------------------------------------------------------------------
1 | import { ChitChatResponse, ShipmentResponse } from './chitchats';
2 | import { SnipcartShipmentRate } from '../interfaces/snipcart';
3 |
4 | export function convertChitChatRatesToSnipCart(
5 | res: ChitChatResponse
6 | ): SnipcartShipmentRate[] {
7 | const chitChatRates = res.data?.shipment?.rates || [];
8 | const rates: SnipcartShipmentRate[] = chitChatRates.map((rate) => ({
9 | cost: parseFloat(rate.payment_amount),
10 | description: `${rate.postage_description} (${rate.delivery_time_description})`,
11 | // TODO: Show delivery dates
12 | // guaranteedDaysToDelivery: 2,
13 | userDefinedId: `${res.data?.shipment.id} --- ${rate.postage_type}`,
14 | }));
15 | return rates;
16 | }
17 |
--------------------------------------------------------------------------------
/app/utils/snipCartAPI.ts:
--------------------------------------------------------------------------------
1 | import dotenv from 'dotenv';
2 | import fetch from 'isomorphic-fetch';
3 | import {
4 | SnipCartOrder,
5 | SnipCartOrdersResponse,
6 | SnipCartProductDefinition,
7 | SnipcartRequestParams,
8 | } from '../interfaces/snipcart';
9 |
10 | dotenv.config();
11 | const endpoint = 'https://app.snipcart.com/api';
12 | const headers = {
13 | Accept: 'application/json',
14 | Authorization: `Basic ${Buffer.from(process.env.SNIPCART_KEY || '').toString(
15 | 'base64'
16 | )}`,
17 | 'Content-Type': 'application/json',
18 | };
19 |
20 | export async function getProducts(): Promise {
21 | const res = await fetch(`${endpoint}/products`, {
22 | headers,
23 | });
24 | const products = await res.json();
25 | return products.items;
26 | }
27 |
28 | export default async function getOrders(
29 | params: SnipcartRequestParams
30 | ): Promise {
31 | const y = params as URLSearchParams;
32 | const paramsString = new URLSearchParams(y).toString();
33 | const res = await fetch(`${endpoint}/orders?${paramsString}`, {
34 | headers,
35 | });
36 | const { items: orders } = (await res.json()) as SnipCartOrdersResponse;
37 | // await fs.writeFileSync('./order-items.json', JSON.stringify(data));
38 | // console.dir(data, { depth: null });
39 | if (orders?.length) {
40 | // console.log(`Back with ${orders.length} Orders!`);
41 | return orders;
42 | }
43 | return [];
44 | }
45 |
46 | export async function getOrder(orderToken: string): Promise {
47 | const res = await fetch(`${endpoint}/orders/${orderToken}`, {
48 | headers,
49 | });
50 | const order = (await res.json()) as SnipCartOrder;
51 | return order;
52 | }
53 |
54 | interface MetaData {
55 | [key: string]: any;
56 | }
57 |
58 | export async function updateOrder(
59 | orderToken: string,
60 | data: MetaData
61 | ): Promise {
62 | const res = await fetch(`${endpoint}/orders/${orderToken}`, {
63 | headers: {
64 | ...headers,
65 | 'Content-Type': 'application/json',
66 | },
67 | method: 'PUT',
68 | body: JSON.stringify(data),
69 | });
70 | return (await res.json()) as SnipCartOrder;
71 | }
72 |
73 | export { endpoint, headers };
74 |
--------------------------------------------------------------------------------
/app/utils/stallion.ts:
--------------------------------------------------------------------------------
1 | // 1. Get Rates from Stallion
2 | // 2. Purchase Shipment from Stallion
3 | // 3. Get tracking From Stallion
4 | // 3. Get Label from Stallion
5 |
6 | // Purchase Shipment
7 | // package_type: For all non US shipments please select Parcel.
8 |
9 | // Get Label
10 | await fetch('https://ship.stallionexpress.ca/api/shipments/print', {
11 | credentials: 'include',
12 | headers: {
13 | Authorization: 'Bearer ABC123',
14 | 'Content-Type': 'application/json;charset=utf-8',
15 | },
16 | referrer: 'https://ship.stallionexpress.ca/shipments',
17 | body: '{"ids":["210224N0CY"],"sort_by":"created_at","sort_order":"asc"}',
18 | method: 'POST',
19 | mode: 'cors',
20 | });
21 |
22 | // Rates Request
23 |
24 | // curl -X POST "https://ship.stallionexpress.ca/api/v3/rates" -H "accept: application/json" -H "Authorization: Bearer XXX" -H "Content-Type: application/json" -d "{ \"name\": \"Pramod Thomson\", \"address1\": \"30 Clearview Dr\", \"address2\": \"Lot 2\", \"city\": \"Rock Springs\", \"province_code\": \"WY\", \"postal_code\": \"82901\", \"country_code\": \"US\", \"weight_unit\": \"lbs\", \"weight\": 0.6, \"length\": 9, \"width\": 12, \"height\": 1, \"size_unit\": \"cm\", \"package_contents\": \"Two pair of socks\", \"value\": 10, \"currency\": \"USD\", \"package_type\": \"legal_flat_rate_envelope\", \"signature_confirmation\": true, \"purchase_label\": true}"
25 |
--------------------------------------------------------------------------------
/app/utils/withAuth.ts:
--------------------------------------------------------------------------------
1 | import { NextApiHandler, NextApiRequest, NextApiResponse } from 'next';
2 | import { getSession } from 'next-auth/client';
3 |
4 | export function withAuth(originalHandler: NextApiHandler) {
5 | return async function handler(req: NextApiRequest, res: NextApiResponse) {
6 | const session = await getSession({ req });
7 | // most basic permissions. If the user's email isn't mine, no access.
8 | if (!session || session.user?.email !== 'wes@wesbos.com') {
9 | res.status(401).json({ message: 'Unauthorized' });
10 | console.log('unauthorized');
11 | return;
12 | }
13 | return originalHandler(req, res);
14 | };
15 | }
16 |
--------------------------------------------------------------------------------