From 00055376f2c75b2ff1116512bcccd5d75b257ad7 Mon Sep 17 00:00:00 2001 From: Grant Forrest Date: Sat, 30 Mar 2024 12:49:03 -0400 Subject: [PATCH] weather API --- packages/client/src/graphql-env.d.ts | 300 +++++++++++++- pnpm-lock.yaml | 8 +- server/package.json | 2 +- server/src/auth/handlers.ts | 41 +- server/src/config/secrets.ts | 5 + server/src/graphql/builder.ts | 15 + server/src/graphql/context.ts | 1 + server/src/graphql/schema.ts | 2 + server/src/graphql/types/common/index.ts | 1 + server/src/graphql/types/common/weather.ts | 119 ++++++ server/src/routers/graphql.ts | 40 +- server/src/server.ts | 12 + server/src/services/weather.ts | 388 ++++++++++++++++++ .../auth/EmailCompleteSignupForm.tsx | 14 +- web/src/components/auth/EmailSignInForm.tsx | 8 +- web/src/components/auth/EmailSignupForm.tsx | 72 +++- web/src/pages/JoinPage.tsx | 2 +- web/src/pages/LoginPage.tsx | 30 +- 18 files changed, 1011 insertions(+), 49 deletions(-) create mode 100644 server/src/graphql/types/common/index.ts create mode 100644 server/src/graphql/types/common/weather.ts create mode 100644 server/src/services/weather.ts diff --git a/packages/client/src/graphql-env.d.ts b/packages/client/src/graphql-env.d.ts index f4a09e85..078c3287 100644 --- a/packages/client/src/graphql-env.d.ts +++ b/packages/client/src/graphql-env.d.ts @@ -567,6 +567,74 @@ export type introspection = { } ] }, + { + "kind": "OBJECT", + "name": "GeographicResult", + "fields": [ + { + "name": "country", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "args": [] + }, + { + "name": "lat", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "args": [] + }, + { + "name": "lon", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "args": [] + }, + { + "name": "name", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "args": [] + }, + { + "name": "state", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "args": [] + } + ], + "interfaces": [] + }, + { + "kind": "SCALAR", + "name": "Float" + }, { "kind": "SCALAR", "name": "JSON" @@ -1635,6 +1703,36 @@ export type introspection = { }, "args": [] }, + { + "name": "geographicLocations", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "GeographicResult", + "ofType": null + } + } + } + }, + "args": [ + { + "name": "search", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + ] + }, { "name": "me", "type": { @@ -1817,6 +1915,30 @@ export type introspection = { } } ] + }, + { + "name": "weatherForecast", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "WeatherForecast", + "ofType": null + } + }, + "args": [ + { + "name": "input", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "INPUT_OBJECT", + "name": "WeatherForecastInput", + "ofType": null + } + } + } + ] } ], "interfaces": [] @@ -2134,10 +2256,6 @@ export type introspection = { ], "interfaces": [] }, - { - "kind": "SCALAR", - "name": "Float" - }, { "kind": "OBJECT", "name": "RecipeScanDetailedStep", @@ -2339,6 +2457,18 @@ export type introspection = { ], "interfaces": [] }, + { + "kind": "ENUM", + "name": "TemperatureUnit", + "enumValues": [ + { + "name": "Celsius" + }, + { + "name": "Fahrenheit" + } + ] + }, { "kind": "INPUT_OBJECT", "name": "UpdateCategoryInput", @@ -2471,6 +2601,168 @@ export type introspection = { "name": "Node" } ] + }, + { + "kind": "OBJECT", + "name": "WeatherForecast", + "fields": [ + { + "name": "days", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "LIST", + "ofType": { + "kind": "NON_NULL", + "ofType": { + "kind": "OBJECT", + "name": "WeatherForecastDay", + "ofType": null + } + } + } + }, + "args": [] + }, + { + "name": "error", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "args": [] + } + ], + "interfaces": [] + }, + { + "kind": "OBJECT", + "name": "WeatherForecastDay", + "fields": [ + { + "name": "date", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "DateTime", + "ofType": null + } + }, + "args": [] + }, + { + "name": "high", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "args": [] + }, + { + "name": "low", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "args": [] + }, + { + "name": "precipitationMM", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "args": [] + }, + { + "name": "temperatureUnit", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "ENUM", + "name": "TemperatureUnit", + "ofType": null + } + }, + "args": [] + } + ], + "interfaces": [] + }, + { + "kind": "INPUT_OBJECT", + "name": "WeatherForecastInput", + "inputFields": [ + { + "name": "endDate", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "DateTime", + "ofType": null + } + } + }, + { + "name": "latitude", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + } + }, + { + "name": "longitude", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + } + }, + { + "name": "startDate", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "SCALAR", + "name": "DateTime", + "ofType": null + } + } + }, + { + "name": "temperatureUnits", + "type": { + "kind": "NON_NULL", + "ofType": { + "kind": "ENUM", + "name": "TemperatureUnit", + "ofType": null + } + } + } + ] } ], "directives": [] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1de95e10..1547587b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -568,8 +568,8 @@ importers: server: dependencies: '@a-type/auth': - specifier: ^0.4.3 - version: 0.4.3 + specifier: ^0.4.8 + version: 0.4.8 '@a-type/utils': specifier: ^1.0.5 version: 1.0.5 @@ -804,8 +804,8 @@ packages: transitivePeerDependencies: - encoding - /@a-type/auth@0.4.3: - resolution: {integrity: sha512-3tHylNWjn4hXu9xHpZplBGcvHEJtkcm4N5Pb7qNvUnflvzeYN3H0nt6plo7ziD3b7wy3MdTjWi5kBWr9TnKbPQ==} + /@a-type/auth@0.4.8: + resolution: {integrity: sha512-1wRdvhi0yCxrpWU3jBBZ5DcolJluZIB7Y/VeGWb5svsk6Juf/u/Gna6sVrHLPXMi0w9vUvf4Sn4XtTjEsbHk1Q==} dependencies: cookie: 0.6.0 discord-oauth2: 2.12.1 diff --git a/server/package.json b/server/package.json index d03d85e5..66698054 100644 --- a/server/package.json +++ b/server/package.json @@ -15,7 +15,7 @@ "type": "module", "private": true, "dependencies": { - "@a-type/auth": "^0.4.3", + "@a-type/auth": "^0.4.8", "@a-type/utils": "^1.0.5", "@biscuits/apps": "workspace:*", "@biscuits/db": "workspace:*", diff --git a/server/src/auth/handlers.ts b/server/src/auth/handlers.ts index eb9b4640..1984aa93 100644 --- a/server/src/auth/handlers.ts +++ b/server/src/auth/handlers.ts @@ -1,6 +1,6 @@ import { GoogleProvider, createHandlers } from '@a-type/auth'; import { assert } from '@a-type/utils'; -import { db, hashPassword, id } from '@biscuits/db'; +import { comparePassword, db, hashPassword, id } from '@biscuits/db'; import { sessions } from '../auth/session.js'; import { DEPLOYED_ORIGIN, UI_ORIGIN } from '../config/deployedContext.js'; import { email } from '../services/email.js'; @@ -13,7 +13,6 @@ assert( export const authHandlers = createHandlers({ sessions, - defaultReturnTo: `/`, returnToOrigin: UI_ORIGIN, providers: { google: new GoogleProvider({ @@ -22,6 +21,8 @@ export const authHandlers = createHandlers({ redirectUri: DEPLOYED_ORIGIN + '/auth/provider/google/callback', }), }, + addProvidersToExistingUsers: true, + defaultReturnToPath: '/', email: email, db: { getAccountByProviderAccountId: async (providerName, providerAccountId) => { @@ -96,10 +97,11 @@ export const authHandlers = createHandlers({ .returning('id') .executeTakeFirstOrThrow(); }, - getVerificationCode: async (id) => { + getVerificationCode: async (email, code) => { const value = await db .selectFrom('VerificationCode') - .where('id', '=', id) + .where('code', '=', code) + .where('email', '=', email) .selectAll() .executeTakeFirst(); @@ -115,13 +117,38 @@ export const authHandlers = createHandlers({ consumeVerificationCode: async (id) => { await db.deleteFrom('VerificationCode').where('id', '=', id).execute(); }, - getUserByEmailAndPassword: async (email, password) => { - return db + getUserByEmailAndPassword: async (email, plaintextPassword) => { + const user = await db .selectFrom('User') .where('email', '=', email) - .where('password', '=', password) .selectAll() .executeTakeFirst(); + + if (!user?.password) { + return undefined; + } + + if (!(await comparePassword(plaintextPassword, user.password))) { + return undefined; + } + + return user; + }, + updateUser: async (id, { plaintextPassword, ...user }) => { + const password = plaintextPassword + ? await hashPassword(plaintextPassword) + : undefined; + await db + .updateTable('User') + .where('id', '=', id) + .set({ + fullName: user.fullName ?? undefined, + emailVerifiedAt: user.emailVerifiedAt ?? undefined, + friendlyName: user.friendlyName ?? undefined, + imageUrl: user.imageUrl ?? undefined, + password, + }) + .executeTakeFirstOrThrow(); }, }, }); diff --git a/server/src/config/secrets.ts b/server/src/config/secrets.ts index 114ce6f6..c6a7cf1d 100644 --- a/server/src/config/secrets.ts +++ b/server/src/config/secrets.ts @@ -3,6 +3,7 @@ import { assert } from '@a-type/utils'; export const VERDANT_SECRET = process.env.VERDANT_SECRET!; export const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!; export const SESSION_SECRET = process.env.SESSION_SECRET!; +export const OPENWEATHER_API_KEY = process.env.OPENWEATHER_API_KEY!; assert(!!VERDANT_SECRET, 'VERDANT_SECRET environment variable must be set'); assert( @@ -10,3 +11,7 @@ assert( 'STRIPE_WEBHOOK_SECRET environment variable must be set', ); assert(!!SESSION_SECRET, 'SESSION_SECRET environment variable must be set'); +assert( + !!OPENWEATHER_API_KEY, + 'OPENWEATHER_API_KEY environment variable must be set', +); diff --git a/server/src/graphql/builder.ts b/server/src/graphql/builder.ts index e19d38c9..0ea6eb5e 100644 --- a/server/src/graphql/builder.ts +++ b/server/src/graphql/builder.ts @@ -15,6 +15,13 @@ import { LibraryInfo } from '@verdant-web/server'; import { BiscuitsVerdantProfile } from '@biscuits/libraries'; import { ExtractorData } from '@gnocchi.biscuits/scanning'; import { BiscuitsError } from '@biscuits/error'; +import { + GeographicResult, + TemperatureUnit, + WeatherForecast, + WeatherForecastDay, + WeatherForecastInput, +} from '../services/weather.js'; export const builder = new SchemaBuilder<{ Context: GQLContext; @@ -63,6 +70,11 @@ export const builder = new SchemaBuilder<{ ExtractorData['detailedIngredients'] >[number]; RecipeScanDetailedStep: NonNullable[number]; + + // Common Utils + WeatherForecast: WeatherForecast; + WeatherForecastDay: WeatherForecastDay; + GeographicResult: GeographicResult; }; AuthScopes: { public: boolean; @@ -123,6 +135,9 @@ export const builder = new SchemaBuilder<{ RecipeScanInput: { url: string; }; + + // Common Utils + WeatherForecastInput: WeatherForecastInput; }; }>({ plugins: [RelayPlugin, DataloaderPlugin, AuthPlugin], diff --git a/server/src/graphql/context.ts b/server/src/graphql/context.ts index 7812089b..1cb49a03 100644 --- a/server/src/graphql/context.ts +++ b/server/src/graphql/context.ts @@ -11,6 +11,7 @@ export type GQLContext = { verdant: VerdantServer; auth: { setLoginSession: (session: Session | null) => Promise; + applyHeaders: Record; }; stripe: Stripe; dataloaders: ReturnType; diff --git a/server/src/graphql/schema.ts b/server/src/graphql/schema.ts index 63090b64..81b5601a 100644 --- a/server/src/graphql/schema.ts +++ b/server/src/graphql/schema.ts @@ -10,6 +10,8 @@ import './types/changelog.js'; import './types/gnocchi/index.js'; +import './types/common/index.js'; + builder.queryType({}); builder.mutationType({}); diff --git a/server/src/graphql/types/common/index.ts b/server/src/graphql/types/common/index.ts new file mode 100644 index 00000000..0e7a80f1 --- /dev/null +++ b/server/src/graphql/types/common/index.ts @@ -0,0 +1 @@ +import './weather.js'; diff --git a/server/src/graphql/types/common/weather.ts b/server/src/graphql/types/common/weather.ts new file mode 100644 index 00000000..3bf0d9ac --- /dev/null +++ b/server/src/graphql/types/common/weather.ts @@ -0,0 +1,119 @@ +import { + TemperatureUnit, + getForecast, + getGeographicLocation, +} from '../../../services/weather.js'; +import { builder } from '../../builder.js'; + +builder.queryFields((t) => ({ + weatherForecast: t.field({ + type: 'WeatherForecast', + args: { + input: t.arg({ + type: 'WeatherForecastInput', + required: true, + }), + }, + nullable: false, + resolve: async (_, { input }, ctx) => { + return getForecast(input); + }, + }), + geographicLocations: t.field({ + type: ['GeographicResult'], + args: { + search: t.arg.string({ + required: true, + }), + }, + nullable: false, + resolve: async (_, { search }, ctx) => { + return getGeographicLocation(search); + }, + }), +})); + +builder.objectType('WeatherForecast', { + description: 'Weather forecast for a location over a given time period', + fields: (t) => ({ + days: t.expose('days', { + type: ['WeatherForecastDay'], + nullable: false, + }), + error: t.exposeString('error', { + nullable: true, + }), + }), +}); + +builder.objectType('WeatherForecastDay', { + description: 'Weather forecast for a single day', + fields: (t) => ({ + date: t.field({ + type: 'DateTime', + nullable: false, + resolve: (parent) => new Date(parent.date), + }), + high: t.exposeFloat('high', { + nullable: false, + }), + low: t.exposeFloat('low', { + nullable: false, + }), + precipitationMM: t.exposeFloat('precipitationMM', { + nullable: false, + }), + temperatureUnit: t.expose('temperatureUnit', { + type: TemperatureUnit, + nullable: false, + }), + }), +}); + +builder.objectType('GeographicResult', { + description: 'Geographic location result', + fields: (t) => ({ + name: t.exposeString('name', { + nullable: false, + }), + latitude: t.exposeFloat('latitude', { + nullable: false, + }), + longitude: t.exposeFloat('longitude', { + nullable: false, + }), + country: t.exposeString('country', { + nullable: false, + }), + state: t.exposeString('state', { + nullable: true, + }), + }), +}); + +builder.inputType('WeatherForecastInput', { + fields: (t) => ({ + startDate: t.field({ + type: 'DateTime', + required: true, + }), + endDate: t.field({ + type: 'DateTime', + required: true, + }), + latitude: t.float({ + required: true, + }), + longitude: t.float({ + required: true, + }), + temperatureUnits: t.field({ + type: TemperatureUnit, + required: true, + }), + }), +}); + +builder.enumType(TemperatureUnit, { + name: 'TemperatureUnit', +}); diff --git a/server/src/routers/graphql.ts b/server/src/routers/graphql.ts index 2b097b8b..6c76360e 100644 --- a/server/src/routers/graphql.ts +++ b/server/src/routers/graphql.ts @@ -1,5 +1,5 @@ import { Router } from 'itty-router'; -import { createYoga, maskError } from 'graphql-yoga'; +import { Plugin, createYoga, maskError } from 'graphql-yoga'; import { db } from '@biscuits/db'; import { schema } from '../graphql/schema.js'; import { GQLContext } from '../graphql/context.js'; @@ -11,9 +11,23 @@ import { GraphQLError } from 'graphql'; import { stripe } from '../services/stripe.js'; import { AuthError } from '@a-type/auth'; import { createDataloaders } from '../graphql/dataloaders/index.js'; +import { OnResponseHook } from '@whatwg-node/server'; + +class ApplyHeadersPlugin implements Plugin<{}, GQLContext> { + onResponse?: OnResponseHook | undefined = (payload) => { + if (payload.serverContext) { + for (const [key, value] of Object.entries( + payload.serverContext.auth.applyHeaders, + )) { + payload.response.headers.set(key, value); + } + } + }; +} const yoga = createYoga({ schema, + graphiql: true, maskedErrors: { maskError: (error, message, isDev) => { const originalError = @@ -31,6 +45,13 @@ const yoga = createYoga({ return maskError(error, message, isDev); }, }, + plugins: [ApplyHeadersPlugin], + fetchAPI: { + Response: Response, + Request: Request, + Blob: Blob, + ReadableStream: ReadableStream, + }, }); export const graphqlRouter = Router({ @@ -62,13 +83,14 @@ async function handleGraphQLRequest(request: Request) { session, db, auth: { + applyHeaders: sessionHeaders, setLoginSession: async (ses: Session | null) => { if (ses) { const { headers } = await sessions.updateSession(ses); - sessionHeaders = headers; + Object.assign(sessionHeaders, headers); } else { const { headers } = sessions.clearSession(); - sessionHeaders = headers; + Object.assign(sessionHeaders, headers); } // also update immediately in the context, so that // resolvers on return values can see the new session @@ -82,13 +104,7 @@ async function handleGraphQLRequest(request: Request) { db, }), }; - const response = await yoga.fetch(request, ctx); - // merge session headers with response headers - return new Response(response.body, { - status: response.status, - headers: { - ...response.headers, - ...sessionHeaders, - }, - }); + + const yogaResponse = await yoga.handle(request, ctx); + return yogaResponse; } diff --git a/server/src/server.ts b/server/src/server.ts index 0b054bcc..d84d7eeb 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -52,6 +52,18 @@ const ittyServer = createServerAdapter((request) => BiscuitsError.Code.SessionExpired, ); return error(biscuitsExpired.statusCode, biscuitsExpired.body); + } else if (reason.statusCode === 409) { + const biscuitsError = new BiscuitsError( + BiscuitsError.Code.Conflict, + 'You have an account with a different login method. Try logging in with a different method.', + ); + return error(biscuitsError.statusCode, biscuitsError.body); + } else if (reason.statusCode === 401) { + const biscuitsError = new BiscuitsError( + BiscuitsError.Code.Unauthorized, + 'Unauthorized', + ); + return error(biscuitsError.statusCode, biscuitsError.body); } } if (reason instanceof BiscuitsError) { diff --git a/server/src/services/weather.ts b/server/src/services/weather.ts new file mode 100644 index 00000000..d57d7cf6 --- /dev/null +++ b/server/src/services/weather.ts @@ -0,0 +1,388 @@ +import { BiscuitsError } from '@biscuits/error'; +import { OPENWEATHER_API_KEY } from '../config/secrets.js'; + +export enum TemperatureUnit { + Celsius = 'C', + Fahrenheit = 'F', +} + +export interface WeatherForecastDay { + date: string; + high: number; + low: number; + precipitationMM: number; + temperatureUnit: TemperatureUnit; +} + +export interface WeatherForecast { + days: WeatherForecastDay[]; + error?: string; +} + +export interface WeatherForecastInput { + startDate: Date; + endDate: Date; + latitude: number; + longitude: number; + temperatureUnits: TemperatureUnit; +} + +export enum WeatherCondition { + ThunderstormLightRain = 200, + ThunderstormRain = 201, + ThunderstormHeavyRain = 202, + ThunderstormLight = 210, + Thunderstorm = 211, + ThunderstormHeavy = 212, + ThunderstormRagged = 221, + ThunderstormLightDrizzle = 230, + ThunderstormDrizzle = 231, + ThunderstormHeavyDrizzle = 232, + + DrizzleLight = 300, + Drizzle = 301, + DrizzleHeavy = 302, + DrizzleLightRain = 310, + DrizzleRain = 311, + DrizzleHeavyRain = 312, + DrizzleLightShowerRain = 313, + DrizzleShowerRain = 314, + DrizzleHeavyShowerRain = 321, + + RainLight = 500, + Rain = 501, + RainHeavy = 502, + RainVeryHeavy = 503, + RainExtreme = 504, + RainFreezing = 511, + RainLightShower = 520, + RainShower = 521, + RainHeavyShower = 522, + RainRaggedShower = 531, + + SnowLight = 600, + Snow = 601, + SnowHeavy = 602, + SnowSleet = 611, + SnowShowerSleet = 612, + SnowLightRain = 615, + SnowRain = 616, + SnowLightShower = 620, + SnowShower = 621, + SnowHeavyShower = 622, + + AtmosphereMist = 701, + AtmosphereSmoke = 711, + AtmosphereHaze = 721, + AtmosphereDust = 731, + AtmosphereFog = 741, + AtmosphereSand = 751, + AtmosphereDustWhirls = 761, + AtmosphereTornado = 781, + + Clear = 800, + + CloudsFew = 801, + CloudsScattered = 802, + CloudsBroken = 803, + CloudsOvercast = 804, +} + +interface WeatherForecastApiResult { + lat: number; + lon: number; + timezone: string; + timezone_offset: number; + daily: { + /** Unix UTC timestamp */ + dt: number; + sunrise: number; + sunset: number; + moonrise: number; + moonset: number; + moon_phase: number; + summary: string; + temp: { + day: number; + min: number; + max: number; + night: number; + eve: number; + morn: number; + }; + feels_like: { + day: number; + night: number; + eve: number; + morn: number; + }; + pressure: number; + humidity: number; + dew_point: number; + wind_speed: number; + wind_deg: number; + wind_gust?: number; + clouds: number; + uvi: number; + /** precipitation probability 0-1 */ + pop: number; + /** volume, mm */ + rain?: number; + /** volume, mm */ + snow?: number; + weather: { + id: WeatherCondition; + main: string; + description: string; + icon: string; + }[]; + }[]; + alerts: { + sender_name: string; + event: string; + start: number; + end: number; + description: string; + tags: string[]; + }[]; +} + +export async function getForecast( + input: WeatherForecastInput, +): Promise { + // validate provided dates - forecast can only + // see 8 days ahead + const now = new Date(); + if (input.endDate < input.startDate) { + throw new BiscuitsError( + BiscuitsError.Code.BadRequest, + 'End date must be after start date', + ); + } + if (input.endDate < now) { + return { + days: [], + }; + } + if (input.startDate > new Date(now.getTime() + 8 * 24 * 60 * 60 * 1000)) { + return { + days: [], + }; + } + if (input.startDate < now) { + input.startDate = now; + } + + const params = new URLSearchParams(); + params.set('lat', input.latitude.toString()); + params.set('lon', input.longitude.toString()); + params.set('appid', OPENWEATHER_API_KEY); + params.set('exclude', 'current,minutely,hourly'); + params.set( + 'units', + input.temperatureUnits === TemperatureUnit.Celsius ? 'metric' : 'imperial', + ); + + const response = await fetch( + `https://api.openweathermap.org/data/3.0/onecall?${params.toString()}`, + ); + if (!response.ok) { + console.error( + 'Failed to fetch weather forecast', + response.statusText, + await response.text().catch(() => ''), + ); + throw new BiscuitsError( + BiscuitsError.Code.Unexpected, + 'Failed to fetch weather forecast', + ); + } + const data: WeatherForecastApiResult = await response.json(); + // match up the forecast days with the requested days + const forecast: WeatherForecastDay[] = []; + + for (const day of data.daily) { + const dayDate = new Date(day.dt * 1000); + if (dayDate < input.startDate || dayDate > input.endDate) { + continue; + } + forecast.push({ + date: dayDate.toISOString(), + high: day.temp.max, + low: day.temp.min, + precipitationMM: day.rain ?? 0, + temperatureUnit: input.temperatureUnits, + }); + } + + // was the end of the forecast range > 1 day behind the end of the + // user-supplied range? then we need to fetch data from the aggregate + // api for those days + const lastDayOfForecast = forecast[forecast.length - 1]?.date; + const mustEstimateFurther = + !lastDayOfForecast || new Date(lastDayOfForecast) < input.endDate; + if (mustEstimateFurther) { + const lastDay = new Date( + lastDayOfForecast ?? input.startDate.getTime() - 24 * 60 * 60 * 1000, + ); + if (lastDay < input.endDate) { + const missingDays = Math.ceil( + (input.endDate.getTime() - lastDay.getTime()) / (24 * 60 * 60 * 1000), + ); + for (let i = 1; i <= missingDays; i++) { + const date = new Date(lastDay.getTime() + i * 24 * 60 * 60 * 1000); + const estimated = await getEstimatedWeather({ + date: date, + latitude: input.latitude, + longitude: input.longitude, + temperatureUnits: input.temperatureUnits, + }); + forecast.push(estimated); + } + } + } + + return { + days: forecast, + error: mustEstimateFurther + ? 'Dates more than 1 week in the future may be inaccurate' + : undefined, + }; +} + +interface WeatherAggregateApiResult { + lat: number; + lon: number; + tz: string; + date: string; + units: 'imperial' | 'metric' | 'standard'; + cloud_cover: { + afternoon: number; + }; + humidity: { + afternoon: number; + }; + pressure: { + afternoon: number; + }; + precipitation: { + total: number; + }; + temperature: { + min: number; + max: number; + afternoon: number; + night: number; + evening: number; + morning: number; + }; + wind: { + max: { + speed: number; + direction: number; + }; + }; +} + +export interface EstimatedWeatherInput { + date: Date; + latitude: number; + longitude: number; + temperatureUnits: TemperatureUnit; +} + +export async function getEstimatedWeather( + input: EstimatedWeatherInput, +): Promise { + const params = new URLSearchParams(); + params.set('lat', input.latitude.toString()); + params.set('lon', input.longitude.toString()); + params.set('appid', OPENWEATHER_API_KEY); + // date must be formatted YYYY-MM-DD + params.set('date', input.date.toISOString().split('T')[0]); + params.set( + 'units', + input.temperatureUnits === TemperatureUnit.Celsius ? 'metric' : 'imperial', + ); + + const response = await fetch( + `https://api.openweathermap.org/data/3.0/onecall/day_summary?${params.toString()}`, + ); + if (!response.ok) { + console.error( + 'Failed to fetch weather forecast', + response.statusText, + await response.text().catch(() => ''), + ); + throw new BiscuitsError( + BiscuitsError.Code.Unexpected, + 'Failed to fetch weather forecast', + ); + } + + const data: WeatherAggregateApiResult = await response.json(); + return { + date: data.date, + high: data.temperature.max, + low: data.temperature.min, + precipitationMM: data.precipitation.total, + temperatureUnit: input.temperatureUnits, + }; +} + +interface GeographicApiResult { + name: string; + local_names?: { + [languageCode: string]: string; + }; + lat: number; + lon: number; + country: string; + state?: string; +} + +export interface GeographicResult { + name: string; + latitude: number; + longitude: number; + country: string; + state?: string; +} + +/** + * Get the geographic location of a place + * @param location The name of the city, state, and country; or a zip code + */ +export async function getGeographicLocation( + location: string, +): Promise { + const params = new URLSearchParams(); + if (ZIP_CODE_REGEX.test(location)) { + params.set('zip', location); + } else { + params.set('q', location); + } + params.set('limit', '10'); + params.set('appid', OPENWEATHER_API_KEY); + const response = await fetch( + `http://api.openweathermap.org/geo/1.0/direct?${params.toString()}`, + ); + if (!response.ok) { + console.error( + 'Failed to fetch geographic location', + response.statusText, + await response.text().catch(() => ''), + ); + return []; + } + const data: GeographicApiResult[] = await response.json(); + return data.map((r) => ({ + latitude: r.lat, + longitude: r.lon, + name: r.name, + country: r.country, + state: r.state, + })); +} + +const ZIP_CODE_REGEX = /^\d{5}(?:-\d{4})?$/; diff --git a/web/src/components/auth/EmailCompleteSignupForm.tsx b/web/src/components/auth/EmailCompleteSignupForm.tsx index ff4c41d4..65fcd291 100644 --- a/web/src/components/auth/EmailCompleteSignupForm.tsx +++ b/web/src/components/auth/EmailCompleteSignupForm.tsx @@ -1,6 +1,7 @@ import { Input } from '@a-type/ui/components/input'; import { Button } from '@a-type/ui/components/button'; import { CONFIG } from '@biscuits/client'; +import { Form } from '@a-type/ui/components/forms'; export interface EmailCompleteSignUpFormProps { code: string; @@ -12,10 +13,13 @@ export function EmailCompleteSignupForm({ email, }: EmailCompleteSignUpFormProps) { return ( -
+ - - + - +
); } diff --git a/web/src/components/auth/EmailSignInForm.tsx b/web/src/components/auth/EmailSignInForm.tsx index 8a77ae6e..7d1c5c29 100644 --- a/web/src/components/auth/EmailSignInForm.tsx +++ b/web/src/components/auth/EmailSignInForm.tsx @@ -17,14 +17,20 @@ export function EmailSignInForm({ returnTo = '' }: EmailSignInFormProps) { className="flex flex-col gap-2" action={`${CONFIG.API_ORIGIN}/auth/email-login?returnTo=${returnTo}`} > + + - diff --git a/web/src/components/auth/EmailSignupForm.tsx b/web/src/components/auth/EmailSignupForm.tsx index 2372efad..f2194c5b 100644 --- a/web/src/components/auth/EmailSignupForm.tsx +++ b/web/src/components/auth/EmailSignupForm.tsx @@ -1,22 +1,74 @@ import { Button } from '@a-type/ui/components/button'; +import { + FormikForm, + SubmitButton, + TextField, +} from '@a-type/ui/components/forms'; import { Input } from '@a-type/ui/components/input'; -import { CONFIG } from '@biscuits/client'; +import { CONFIG, fetch } from '@biscuits/client'; +import { useState } from 'react'; +import { toast } from 'react-hot-toast'; export interface EmailSignUpFormProps { returnTo?: string | null; + actionText?: string; } -export function EmailSignUpForm({ returnTo }: EmailSignUpFormProps) { +export function EmailSignUpForm({ + returnTo, + actionText = 'Start signup', +}: EmailSignUpFormProps) { + const [success, setSuccess] = useState(false); + + if (success) { + return ( +
+

Check your email for a verification code.

+
+ ); + } + return ( -
{ + try { + // this API accepts form data + const formData = new FormData(); + formData.append('name', values.name); + formData.append('email', values.email); + formData.append('returnTo', returnTo ?? ''); + const response = await fetch( + `${CONFIG.API_ORIGIN}/auth/begin-email-signup`, + { + method: 'post', + body: formData, + headers: {}, + }, + ); + if (response.ok) { + setSuccess(true); + } + } catch (e) { + console.error(e); + } + }} className="flex flex-col gap-2" > - - - -
+ + + + {actionText} + + ); } diff --git a/web/src/pages/JoinPage.tsx b/web/src/pages/JoinPage.tsx index df059c7f..79889bd7 100644 --- a/web/src/pages/JoinPage.tsx +++ b/web/src/pages/JoinPage.tsx @@ -29,7 +29,7 @@ export function JoinPage({}: JoinPageProps) { const navigate = useNavigate(); useEffect(() => { if (seen) { - navigate(`/login`); + navigate(`/login`, { replace: true }); } }, [seen, navigate]); diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index c6c906f7..ab62152e 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -23,7 +23,7 @@ export default function LoginPage() {
-
+

Join the club

@@ -41,26 +41,36 @@ export default function LoginPage() { Log in Create account - -

Welcome!

+ +

Welcome!

Sign up with Google +
- -

Welcome back!

+ +

Welcome back!

Sign in with Google +
@@ -68,3 +78,13 @@ export default function LoginPage() {
); } + +function Or() { + return ( +
+
+

or

+
+
+ ); +}