create-wirejs-app 2.0.111 → 2.0.112-payments

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-wirejs-app",
3
- "version": "2.0.111",
3
+ "version": "2.0.112-payments",
4
4
  "description": "Initializes a wirejs package.",
5
5
  "author": "Jon Wire",
6
6
  "license": "MIT",
@@ -0,0 +1,85 @@
1
+ import {
2
+ AuthenticationApi,
3
+ Endpoint as InternalEndpoint,
4
+ Setting as InternalSetting,
5
+ withContext,
6
+ } from "wirejs-resources";
7
+
8
+ const permanentAdmins = ['admin'];
9
+
10
+ export type Setting = {
11
+ key: string;
12
+ value: string;
13
+ description: string;
14
+ isPrivate: boolean;
15
+ options?: string[];
16
+ };
17
+
18
+ export type Endpoint = {
19
+ id: string;
20
+ path: string;
21
+ url: string;
22
+ description: string;
23
+ };
24
+
25
+ export type SystemAttribute = {
26
+ name: string;
27
+ value: string;
28
+ description: string;
29
+ };
30
+
31
+ export const Admin = (auth: AuthenticationApi) => withContext(context => {
32
+ const api = {
33
+ async isAdmin() {
34
+ const user = await auth.getCurrentUser(context);
35
+ if (!user) return false;
36
+ return permanentAdmins.includes(user.username);
37
+ },
38
+ async requireAdmin() {
39
+ if (!await api.isAdmin()) throw new Error("You are not an admin.");
40
+ },
41
+ async listSettings() {
42
+ await api.requireAdmin();
43
+ let results: Setting[] = [];
44
+ for (const setting of InternalSetting.list()) {
45
+ results.push({
46
+ key: setting.absoluteId,
47
+ value: await setting.read(),
48
+ description: setting.description || '',
49
+ options: setting.options,
50
+ isPrivate: setting.isPrivate,
51
+ });
52
+ }
53
+ return results;
54
+ },
55
+ async saveSettings(settings: { key: string, value: string }[]) {
56
+ await api.requireAdmin();
57
+ for (const { key, value } of settings) {
58
+ const setting = InternalSetting.get(key);
59
+ if (setting) await setting.write(value)
60
+ }
61
+ },
62
+ async listEndpoints() {
63
+ await api.requireAdmin();
64
+ const endpoints: Endpoint[] = [];
65
+ for (const endpoint of InternalEndpoint.list()) {
66
+ endpoints.push({
67
+ id: endpoint.absoluteId,
68
+ description: endpoint.description || '',
69
+ path: endpoint.path,
70
+ url: await endpoint.determineUrl(),
71
+ });
72
+ }
73
+ return endpoints;
74
+ },
75
+ async listSystemAttributes(): Promise<SystemAttribute[]> {
76
+ await api.requireAdmin();
77
+ return context.systemInfo.map(attr => ({
78
+ name: attr.name,
79
+ value: attr.value,
80
+ description: attr.description
81
+ })).toArray();
82
+ },
83
+ };
84
+ return api;
85
+ });
@@ -41,7 +41,7 @@ export const Chat = (auth: AuthenticationApi) => withContext(context => ({
41
41
  },
42
42
  async getRoom(room: string) {
43
43
  await auth.requireCurrentUser(context);
44
- return realtimeService.getStream(sanitizedRoomName(room));
44
+ return realtimeService.getStream(context, sanitizedRoomName(room));
45
45
  },
46
46
  async startCountdown(room: string, seconds: number) {
47
47
  await auth.requireCurrentUser(context);
@@ -0,0 +1,105 @@
1
+ import { AuthenticationApi, withContext } from 'wirejs-resources';
2
+ import {
3
+ PaymentService,
4
+ OneTimeProduct,
5
+ SubscriptionProduct,
6
+ OneTimePurchaseLineItem,
7
+ SubscriptionLineItem,
8
+ } from 'wirejs-module-payments-stripe';
9
+
10
+ export type { Product, Transaction, SubscriptionLine, OneTimePurchaseLineItem } from 'wirejs-module-payments-stripe';
11
+
12
+ const payments = new PaymentService('app', 'payments');
13
+
14
+ const products: OneTimeProduct[] = [
15
+ {
16
+ id: 'something-a',
17
+ name: 'something a',
18
+ type: 'one_time',
19
+ currency: 'usd',
20
+ unitAmount: 2345,
21
+ metadata: {}
22
+ },
23
+ {
24
+ id: 'something-b',
25
+ name: 'something b',
26
+ type: 'one_time',
27
+ currency: 'usd',
28
+ unitAmount: 1234,
29
+ metadata: {}
30
+ },
31
+ {
32
+ id: 'something-c',
33
+ name: 'something c',
34
+ type: 'one_time',
35
+ currency: 'usd',
36
+ unitAmount: 999,
37
+ metadata: {}
38
+ },
39
+ ];
40
+
41
+ const plans: SubscriptionProduct[] = [
42
+ {
43
+ id: 'plan-a',
44
+ name: 'plan a',
45
+ type: 'recurring',
46
+ currency: 'usd',
47
+ unitAmount: 1299,
48
+ interval: 'month',
49
+ metadata: {}
50
+ }
51
+ ];
52
+
53
+ export const Store = (auth: AuthenticationApi) => withContext(context => ({
54
+ async listProducts() {
55
+ return [...products, ...plans];
56
+ },
57
+ async getCheckoutUrl({ cart, successUrl, cancelUrl }: {
58
+ cart: { id: string, quantity: number }[];
59
+ successUrl: string;
60
+ cancelUrl: string;
61
+ }) {
62
+ const user = await auth.requireCurrentUser(context);
63
+ return payments.createCheckoutUrl({
64
+ customer: {
65
+ id: user.id
66
+ },
67
+ lineItems: cart.map(({id, quantity}): OneTimePurchaseLineItem => {
68
+ return {
69
+ product: products.find(p => p.id === id)!,
70
+ quantity
71
+ };
72
+ }),
73
+ successUrl,
74
+ cancelUrl,
75
+ });
76
+ },
77
+ async getSubscribeUrl({ cart, successUrl, cancelUrl }: {
78
+ cart: { id: string }[];
79
+ successUrl: string;
80
+ cancelUrl: string;
81
+ }) {
82
+ const user = await auth.requireCurrentUser(context);
83
+ return payments.createCheckoutUrl({
84
+ customer: {
85
+ id: user.id
86
+ },
87
+ lineItems: cart.map(({id}): SubscriptionLineItem => {
88
+ return {
89
+ product: plans.find(p => p.id === id)!,
90
+ quantity: 1
91
+ };
92
+ }),
93
+ successUrl,
94
+ cancelUrl,
95
+ });
96
+ },
97
+ async listPayments() {
98
+ const user = await auth.requireCurrentUser(context);
99
+ return payments.listPayments(user.id);
100
+ },
101
+ async listSubscriptions() {
102
+ const user = await auth.requireCurrentUser(context);
103
+ return payments.listSubscriptions(user.id);
104
+ }
105
+ }));
@@ -1,9 +1,13 @@
1
- import { AuthenticationService} from 'wirejs-resources';
1
+ import { AuthenticationService, Endpoint } from 'wirejs-resources';
2
2
  import { Chat } from './apps/chat.js';
3
3
  import { Todos } from './apps/todos.js';
4
4
  import { Wiki } from './apps/wiki.js';
5
+ import { Store } from './apps/store.js';
6
+ import { Admin } from './apps/admin.js';
5
7
 
6
- export type { Todo } from './apps/todos.js';
8
+ export type * from './apps/todos.js';
9
+ export type * from './apps/store.js';
10
+ export type * from './apps/admin.js';
7
11
 
8
12
  const authService = new AuthenticationService('app', 'core-users');
9
13
 
@@ -11,3 +15,28 @@ export const auth = authService.buildApi();
11
15
  export const chat = Chat(auth);
12
16
  export const todos = Todos(auth);
13
17
  export const wiki = Wiki(auth);
18
+ export const store = Store(auth);
19
+ export const admin = Admin(auth);
20
+
21
+ new Endpoint('app', 'sample-endpoint', {
22
+ description: "Sample endpoint to show dynamic endpoint creation.",
23
+ handle() {
24
+ return "<html><body><p>Hello!</p><p><a href='/'>Back.</a></body></html>";
25
+ }
26
+ });
27
+
28
+ new Endpoint('app', 'sample-wildcard-endpoint', {
29
+ path: 'wildcard-endpoint/%',
30
+ description: "Sample endpoint to show dynamic wildcard endpoint creation.",
31
+ handle(context) {
32
+ return `<html>
33
+ <body>
34
+ <h2>${context.location.toString()
35
+ .replace(/</, '&lt;')
36
+ .replace(/>/, '&gt;')
37
+ }</h2>
38
+ <p><a href='/'>Back.</a>
39
+ </body>
40
+ </html>`;
41
+ }
42
+ });
@@ -11,19 +11,21 @@
11
11
  "dependencies": {
12
12
  "dompurify": "^3.2.3",
13
13
  "marked": "^15.0.6",
14
- "wirejs-dom": "^1.0.41",
15
- "wirejs-resources": "^0.1.106",
16
- "wirejs-components": "^0.1.49",
17
- "wirejs-web-worker": "^1.0.3"
14
+ "wirejs-dom": "^1.0.42",
15
+ "wirejs-resources": "^0.1.107-payments",
16
+ "wirejs-components": "^0.1.50-payments",
17
+ "wirejs-module-payments-stripe": "^0.1.1-payments",
18
+ "wirejs-web-worker": "^1.0.4-payments"
18
19
  },
19
20
  "devDependencies": {
20
- "wirejs-scripts": "^3.0.104",
21
+ "wirejs-scripts": "^3.0.105-payments",
21
22
  "typescript": "^5.7.3"
22
23
  },
23
24
  "scripts": {
24
25
  "prebuild": "npm run prebuild --workspaces --if-present",
25
26
  "prestart": "npm run prestart --workspaces --if-present",
26
27
  "start": "wirejs-scripts ws-run-parallel start",
28
+ "start:public": "wirejs-scripts ws-run-parallel start:public",
27
29
  "build": "npm run build --workspaces --if-present"
28
30
  },
29
31
  "engines": {
@@ -5,10 +5,11 @@
5
5
  "type": "module",
6
6
  "dependencies": {
7
7
  "wirejs-dom": "*",
8
- "my-api": "*"
8
+ "internal-api": "*"
9
9
  },
10
10
  "scripts": {
11
11
  "build": "cd .. && wirejs-scripts build",
12
- "start": "cd .. && wirejs-scripts start"
12
+ "start": "cd .. && wirejs-scripts start",
13
+ "start:public": "cd .. && wirejs-scripts start --public"
13
14
  }
14
15
  }
@@ -0,0 +1,175 @@
1
+ import { html, list, node, text, hydrate } from 'wirejs-dom/v2';
2
+ import { Main } from '../layouts/main.js';
3
+ import { admin, Endpoint, Setting, SystemAttribute } from 'internal-api';
4
+
5
+ function SettingInput(setting: Setting, dirtySettings: Map<string, string>) {
6
+ if (setting.isPrivate) {
7
+ return html`<input
8
+ type="password"
9
+ value="${setting.value || ''}"
10
+ oninput=${(event: Event) => dirtySettings.set(
11
+ setting.key, (event.target as any).value
12
+ )}
13
+ />`;
14
+ }
15
+
16
+ if (setting.options) {
17
+ return html`<select
18
+ oninput=${(event: Event) => dirtySettings.set(
19
+ setting.key, (event.target as any).value
20
+ )}>${setting.options.map(option =>
21
+ html`<option
22
+ value=${option}
23
+ ${setting.value === option ? 'selected' : ''}
24
+ >${option}</option>`)
25
+ }</select>`;
26
+ }
27
+
28
+ return html`<input
29
+ type="text"
30
+ value="${setting.value || ''}"
31
+ oninput=${(event: Event) => dirtySettings.set(
32
+ setting.key, (event.target as any).value
33
+ )}
34
+ />`;
35
+ }
36
+
37
+ function Admin() {
38
+ const dirtySettings = new Map<string, string>();
39
+
40
+ const styledLink = (endpoint: Endpoint) => {
41
+ const origin = endpoint.url.slice(0, endpoint.path.length);
42
+ return html`<span>
43
+ <a
44
+ href='${endpoint.path}'
45
+ style='font-weight: normal; color: gray;'
46
+ >${origin}<b style='color: black;'>${endpoint.path}</b></a>
47
+
48
+ </span>`;
49
+ }
50
+
51
+ const copyLinkButton = (endpoint: Endpoint) => {
52
+ const COPY_ICON = "📋";
53
+ const COPIED_ICON = "✅";
54
+ const self = html`<span
55
+ style="cursor: pointer; margin-left: 0.5em; font-size: smaller;"
56
+ title="Copy to clipboard"
57
+ onclick=${() => {
58
+ navigator.clipboard.writeText(endpoint.url);
59
+ self.data.status = COPIED_ICON;
60
+ setTimeout(() => self.data.status = COPY_ICON, 1500);
61
+ }}
62
+ >${text('status', COPY_ICON)}
63
+ </span>`;
64
+ return self;
65
+ }
66
+
67
+ const copyValueButton = (value: string) => {
68
+ const COPY_ICON = "📋";
69
+ const COPIED_ICON = "✅";
70
+ const self = html`<span
71
+ style="cursor: pointer; margin-left: 0.5em; font-size: smaller;"
72
+ title="Copy to clipboard"
73
+ onclick=${() => {
74
+ navigator.clipboard.writeText(value);
75
+ self.data.status = COPIED_ICON;
76
+ setTimeout(() => self.data.status = COPY_ICON, 1500);
77
+ }}
78
+ >${text('status', COPY_ICON)}
79
+ </span>`;
80
+ return self;
81
+ }
82
+
83
+ const load = async () => {
84
+ self.data.settings = (await admin.listSettings(null))
85
+ .sort((a, b) => a.key > b.key ? 1 : -1);
86
+ self.data.endpoints = (await admin.listEndpoints(null))
87
+ .sort((a, b) => a.id > b.id ? 1 : -1);
88
+ self.data.attributes = (await admin.listSystemAttributes(null))
89
+ .sort((a, b) => a.name > b.name ? 1 : -1);
90
+ };
91
+
92
+ const self = html`<div>
93
+ <h3>Endpoints</h3>
94
+ <table>
95
+ ${list('endpoints', (endpoint: Endpoint) => html`<tr>
96
+ <td>
97
+ <b>${endpoint.id}</b>
98
+ <br /><span style='opacity: 0.75'>${endpoint.description}</span>
99
+ </td>
100
+ <td>&rarr;</td>
101
+ <td>${styledLink(endpoint)}</td>
102
+ <td>${copyLinkButton(endpoint)}</td>
103
+ </tr>`)}
104
+ </table>
105
+
106
+ <h3>Settings</h3>
107
+ <form onsubmit=${async (event: Event) => {
108
+ event.preventDefault();
109
+ self.data.status = 'Saving ...';
110
+ const settings = [...dirtySettings
111
+ .entries()
112
+ .map(([key, value]) => ({
113
+ key,
114
+ value,
115
+ }))
116
+ ];
117
+ dirtySettings.clear();
118
+ try {
119
+ await admin.saveSettings(null, settings);
120
+ await load();
121
+ self.data.status = 'Saved.';
122
+ } catch {
123
+ self.data.status = 'Error Saving.';
124
+ }
125
+ }}>
126
+ <table>
127
+ ${list('settings', [], (setting: Setting) => html`<tr>
128
+ <td><b>${setting.key}</b>${setting.description
129
+ ? `<br /><span style='opacity: 0.75;'>${setting.description}</span>`
130
+ : ''
131
+ }</td>
132
+ <td>${SettingInput(setting, dirtySettings)}</td>
133
+ </tr>`)}
134
+ </table>
135
+ <input type='submit' value='Save Settings' />
136
+ <span>${text('status', 'Loaded')}</span>
137
+ </form>
138
+
139
+ <h3>System Information</h3>
140
+ <table>
141
+ ${list('attributes', (attr: SystemAttribute) => html`<tr>
142
+ <td>
143
+ <b>${attr.name}</b>
144
+ <br /><span style='opacity: 0.75'>${attr.description}</span>
145
+ </td>
146
+ <td>${attr.value || `<span style='opacity: 0.75;'>EMPTY</span>`}</td>
147
+ <td>${copyValueButton(attr.value)}</td>
148
+ </tr>`)}
149
+ </table>
150
+ <div>`.onadd(load);
151
+
152
+ return self;
153
+ }
154
+
155
+ async function App() {
156
+ const self = html`<div id='app'>
157
+ ${node('isAdmin', false, (isAdmin: boolean | undefined) =>
158
+ isAdmin ?
159
+ html`<div><p>Your <b>are</b> an admin.</p>${Admin()}</div>`
160
+ : html`<p>You are <b>NOT</b> an admin.</p>`
161
+ )}
162
+ </div>`.onadd(async self => {
163
+ self.data.isAdmin = await admin.isAdmin(null);
164
+ });
165
+ return self;
166
+ }
167
+
168
+ export async function generate() {
169
+ return Main({
170
+ pageTitle: 'Admin',
171
+ content: await App(),
172
+ });
173
+ }
174
+
175
+ hydrate('app', App as any);
@@ -12,6 +12,8 @@ export async function generate() {
12
12
  <li><a href='/simple-wiki/index.html'>Simple Wiki</a></li>
13
13
  <li><a href='/realtime-test.html'>Realtime Test</a></li>
14
14
  <li><a href='/web-worker-test.html'>Web Worker Test</a></li>
15
+ <li><a href='/storefront.html'>Storefront</a></li>
16
+ <li><a href='/admin.html'>Admin</a></li>
15
17
  </ul>
16
18
  </div>`
17
19
  })
@@ -0,0 +1,174 @@
1
+ import { html, list, attribute, hydrate } from 'wirejs-dom/v2';
2
+ import { AuthenticatedContent } from 'wirejs-components';
3
+ import { store, Product, Transaction, SubscriptionLine } from 'internal-api';
4
+ import { Main } from '../layouts/main.js';
5
+
6
+ type LineItem = {
7
+ productId: string;
8
+ productName: string;
9
+ price: string;
10
+ quantity: number;
11
+ }
12
+
13
+ function Storefront() {
14
+ const self = html`<div>
15
+
16
+ <h4>Products</h4>
17
+ <ol>${list('products', (p: Product) => html`<li>
18
+ ${p.name} : <span
19
+ style='color: darkgreen; font-weight: bold; cursor: pointer;'
20
+ onclick=${() => {
21
+ const existing = self.data.cart.find(li => li.productId === p.id);
22
+ if (existing) {
23
+ const idx = self.data.cart.indexOf(existing);
24
+ self.data.cart.splice(idx, 1, {
25
+ ...existing,
26
+ quantity: existing.quantity + 1
27
+ });
28
+ } else {
29
+ self.data.cart.push({
30
+ productId: p.id,
31
+ productName: p.name,
32
+ price: `\$${(p.unitAmount/100).toFixed(2)}`,
33
+ quantity: 1
34
+ });
35
+ }
36
+ }}
37
+ >add</span>
38
+ </li>`)}</ol>
39
+
40
+ <h4>Cart</h4>
41
+ <ol>${list('cart', (li: LineItem) => html`<li>
42
+ ${li.productName} x ${li.quantity} : <span
43
+ style='color: darkred; font-weight: bold; cursor: pointer;'
44
+ onclick=${() => {
45
+ self.data.cart.splice(self.data.cart.indexOf(li), 1);
46
+ }}
47
+ >remove</span>
48
+ </li>`)}</ol>
49
+
50
+ <div>
51
+ <form onsubmit=${async (event: Event) => {
52
+ event.preventDefault();
53
+ document.location = (await store.getCheckoutUrl(null, {
54
+ cart: self.data.cart.map(li => ({
55
+ id: li.productId,
56
+ quantity: li.quantity
57
+ })),
58
+ successUrl: document.location.href,
59
+ cancelUrl: document.location.href,
60
+ }))!;
61
+ }}>
62
+ <input type='submit' value='Checkout' />
63
+ </form>
64
+ </div>
65
+
66
+ <h4>Transactions</h4>
67
+ <table>
68
+ <tr>
69
+ <th>date</th>
70
+ <th>amount<th>
71
+ <th>items</th>
72
+ </tr>
73
+ ${list('transactions', (t: Transaction) => html`<tr>
74
+ <td>${new Date(t.createdAt).toLocaleDateString()}</td>
75
+ <td>\$${(t.amount/100).toFixed(2)}</td>
76
+ <td><table>
77
+ ${(t.items || []).map(li => html`<tr>
78
+ <td>${li.description}</td>
79
+ <td>x ${li.quantity}</td>
80
+ <td>= \$${(li.amount/100).toFixed(2)}</td>
81
+ </tr>`)}
82
+ </table></td>
83
+ </tr>`)}
84
+ </table>
85
+
86
+ <h4>Subscription Plans</h4>
87
+ <table>
88
+ <tr>
89
+ <th>Name</th>
90
+ <th>Price</th>
91
+ <th></th>
92
+ </tr>
93
+ ${list('plans', (p: Product) => html`<tr>
94
+ <td>${p.name}</td>
95
+ <td>${(p.unitAmount/100).toFixed(2)}/${p.interval}</td>
96
+ <td
97
+ style='color: darkgreen; font-weight: bold; cursor: pointer;'
98
+ onclick=${() => {
99
+ self.data.planCart.push(p);
100
+ self.data.plans.splice(self.data.plans.indexOf(p), 1);
101
+ }}
102
+ >add</span>
103
+ </tr>`)}
104
+ </table>
105
+
106
+ <h4>Subscription Cart</h4>
107
+ <table>
108
+ <tr>
109
+ <th>Name</th>
110
+ <th>Price</th>
111
+ <th></th>
112
+ </tr>
113
+ ${list('planCart', (p: Product) => html`<tr>
114
+ <td>${p.name}</td>
115
+ <td>${(p.unitAmount/100).toFixed(2)}/${p.interval}</td>
116
+ <td><span
117
+ style='color: darkred; font-weight: bold; cursor: pointer;'
118
+ onclick=${() => {
119
+ self.data.planCart.splice(self.data.planCart.indexOf(p), 1);
120
+ self.data.plans.push(p);
121
+ }}
122
+ >remove</span></td>
123
+ </tr>`)}
124
+ </table>
125
+
126
+ <div>
127
+ <form onsubmit=${async (event: Event) => {
128
+ event.preventDefault();
129
+ document.location = (await store.getSubscribeUrl(null, {
130
+ cart: self.data.planCart.map(plan => ({ id: plan.id })),
131
+ successUrl: document.location.href,
132
+ cancelUrl: document.location.href,
133
+ }))!;
134
+ }}>
135
+ <input type='submit' value='Subscribe' />
136
+ </form>
137
+ </div>
138
+
139
+ <h4>Subscriptions</h4>
140
+ <table>${list('subscriptions', (s: SubscriptionLine) => html`<li>
141
+ ${JSON.stringify(s)}
142
+ </li>`)}</table>
143
+
144
+ <div>`.onadd(async self => {
145
+ const products = await store.listProducts(null);
146
+ self.data.products = products.filter(p => p.type === 'one_time');
147
+ self.data.plans = products.filter(p => p.type !== 'one_time');
148
+ self.data.transactions = await store.listPayments(null);
149
+ self.data.subscriptions = await store.listSubscriptions(null);
150
+ });
151
+ return self;
152
+ }
153
+
154
+ async function App() {
155
+ const self = html`<div id='app'>
156
+ ${await AuthenticatedContent({
157
+ authenticated: () => Storefront(),
158
+ unauthenticated: () => html`<div>
159
+ You need to sign in to buy things.
160
+ </div>`
161
+ })}
162
+ </div>`;
163
+
164
+ return self;
165
+ }
166
+
167
+ export async function generate() {
168
+ return Main({
169
+ pageTitle: 'Simple Storefront Demo',
170
+ content: await App(),
171
+ });
172
+ }
173
+
174
+ hydrate('app', App as any);
@@ -50,7 +50,7 @@ form div {
50
50
  margin-bottom: 1rem;
51
51
  }
52
52
 
53
- label, input, button {
53
+ label, input, select, button {
54
54
  display: inline-block;
55
55
  width: 9rem;
56
56
  margin: 0.25rem;
@@ -62,7 +62,7 @@ label {
62
62
  color: var(--color-muted);
63
63
  }
64
64
 
65
- input {
65
+ input, select {
66
66
  width: 20rem;
67
67
  border: 1px solid var(--border-color-muted);
68
68
  margin-bottom: 0.5rem;
@@ -70,6 +70,10 @@ input {
70
70
  font-size: medium;
71
71
  }
72
72
 
73
+ select {
74
+ width: 21rem;
75
+ }
76
+
73
77
  textarea {
74
78
  width: calc(100% - 2rem);
75
79
  height: 15rem;