12 implement twilio sms (#37)
* add twilio sdk * add twilio config use app version in config * remove default config * phone regex * bun update * create client * fix env var * create Textarea component * move TextInput * allow snippets on labels * update with label and error * move button * make button children snippet * add form props * allow region code * add twilio FROM number * rename to twilioClient * implement simple messaging * add twilio phone number as empty var * format * move twilio client to local on requests * fix story * on second thought, dont use locals since we are only using twilio in one place Don't want to init a twilio client on every request when its only used in on a single page * use i18n for page text * validate env with a script * remove Zod validation when loading env vars
This commit is contained in:
parent
a4d998665b
commit
1c5b37b24b
29 changed files with 418 additions and 119 deletions
|
|
@ -1,7 +1,8 @@
|
|||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import Button from './Button.svelte';
|
||||
import { fn } from '@storybook/test';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import Button from './Button.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Actions/Button',
|
||||
|
|
@ -10,6 +11,7 @@
|
|||
onClick: fn(),
|
||||
},
|
||||
argTypes: {
|
||||
block: { control: 'boolean' },
|
||||
color: {
|
||||
control: 'select',
|
||||
options: [
|
||||
|
|
@ -25,9 +27,12 @@
|
|||
'error',
|
||||
],
|
||||
},
|
||||
full: { control: 'boolean' },
|
||||
glass: { control: 'boolean' },
|
||||
outline: {
|
||||
control: 'boolean',
|
||||
},
|
||||
responsive: { control: 'boolean' },
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['Default', 'xs', 'sm', 'lg'],
|
||||
|
|
@ -37,8 +42,13 @@
|
|||
control: 'select',
|
||||
options: ['button', 'reset', 'submit'],
|
||||
},
|
||||
wide: { control: 'boolean' },
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story name="Default" args={{ label: 'Button', color: 'primary' }} />
|
||||
{#snippet template({ children: _, ...props }: Partial<ComponentProps<typeof Button>>)}
|
||||
<Button {...props}>Button</Button>
|
||||
{/snippet}
|
||||
|
||||
<Story name="Default" args={{}} children={template} />
|
||||
|
|
@ -1,12 +1,14 @@
|
|||
<script lang="ts">
|
||||
import type { DaisyColor, DaisySize } from '$lib/types';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLButtonAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props {
|
||||
block?: boolean;
|
||||
children: Snippet;
|
||||
color?: DaisyColor;
|
||||
full?: boolean;
|
||||
glass?: boolean;
|
||||
label: string;
|
||||
outline?: boolean;
|
||||
onClick?: () => void;
|
||||
responsive?: boolean;
|
||||
|
|
@ -17,9 +19,10 @@
|
|||
|
||||
let {
|
||||
block = false,
|
||||
children,
|
||||
color,
|
||||
full = false,
|
||||
glass = false,
|
||||
label,
|
||||
outline = false,
|
||||
onClick,
|
||||
responsive = false,
|
||||
|
|
@ -35,6 +38,7 @@
|
|||
class:btn-outline={outline}
|
||||
class:btn-block={block}
|
||||
class:btn-wide={wide}
|
||||
class:w-full={full}
|
||||
class:glass
|
||||
class:btn-xs={size === 'xs'}
|
||||
class:btn-sm={size === 'sm'}
|
||||
|
|
@ -51,7 +55,7 @@
|
|||
class:btn-error={color === 'error'}
|
||||
class={`btn ${responsive && 'btn-xs sm:btn-sm md:btn-md lg:btn-lg'}`}
|
||||
>
|
||||
{label}
|
||||
{@render children()}
|
||||
</button>
|
||||
|
||||
<style></style>
|
||||
1
src/lib/components/Actions/index.ts
Normal file
1
src/lib/components/Actions/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as Button } from './Button.svelte';
|
||||
|
|
@ -6,27 +6,36 @@
|
|||
title: 'Data Input/Text Input',
|
||||
component: TextInput,
|
||||
argTypes: {
|
||||
bordered: {
|
||||
control: 'boolean',
|
||||
},
|
||||
color: {
|
||||
control: 'select',
|
||||
options: [
|
||||
'ghost',
|
||||
'primary',
|
||||
'secondary',
|
||||
'accent',
|
||||
'ghost',
|
||||
'link',
|
||||
'info',
|
||||
'success',
|
||||
'warning',
|
||||
'error',
|
||||
],
|
||||
},
|
||||
bordered: {
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
},
|
||||
error: {
|
||||
control: 'text',
|
||||
},
|
||||
fade: { control: 'boolean' },
|
||||
start: { control: 'text' },
|
||||
end: { control: 'text' },
|
||||
label: { control: 'text' },
|
||||
placeholder: { control: 'text' },
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['Default', 'xs', 'sm', 'lg'],
|
||||
defaultValue: 'Default',
|
||||
options: ['xs', 'sm', '-', 'lg'],
|
||||
},
|
||||
type: {
|
||||
control: 'select',
|
||||
99
src/lib/components/DataInput/TextInput.svelte
Normal file
99
src/lib/components/DataInput/TextInput.svelte
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<script lang="ts">
|
||||
import type { DaisyColor, DaisySize } from '$lib/types';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLInputTypeAttribute } from 'svelte/elements';
|
||||
import { fade as fadeTransition } from 'svelte/transition';
|
||||
|
||||
type Props = {
|
||||
bordered?: boolean;
|
||||
color?: Exclude<DaisyColor, 'neutral'>;
|
||||
disabled?: boolean;
|
||||
error?: string | Snippet;
|
||||
fade?: boolean;
|
||||
start?: string | Snippet;
|
||||
end?: string | Snippet;
|
||||
label?: string | Snippet;
|
||||
name: string;
|
||||
placeholder?: string;
|
||||
size?: DaisySize;
|
||||
type?: Extract<
|
||||
HTMLInputTypeAttribute,
|
||||
'email' | 'password' | 'search' | 'tel' | 'text' | 'url'
|
||||
>;
|
||||
};
|
||||
|
||||
let {
|
||||
bordered = false,
|
||||
color,
|
||||
disabled,
|
||||
error,
|
||||
fade,
|
||||
start,
|
||||
end,
|
||||
label,
|
||||
name,
|
||||
placeholder,
|
||||
size,
|
||||
type = 'text',
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<label class="form-control w-full">
|
||||
<div class="label">
|
||||
<span
|
||||
class="label-text"
|
||||
class:text-primary={color === 'primary'}
|
||||
class:text-secondary={color === 'secondary'}
|
||||
class:text-accent={color === 'accent'}
|
||||
class:text-info={color === 'info'}
|
||||
class:text-success={color === 'success'}
|
||||
class:text-warning={color === 'warning'}
|
||||
class:text-error={color === 'error' || error}
|
||||
>
|
||||
{#if typeof label === 'string'}
|
||||
{label}
|
||||
{:else if label}
|
||||
{@render label()}
|
||||
{/if}
|
||||
</span>
|
||||
<span class="label-text-alt font-semibold text-error">
|
||||
{#if typeof error === 'string'}
|
||||
{error}
|
||||
{:else if error}
|
||||
{@render error()}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
transition:fadeTransition={{ duration: fade ? 200 : 0 }}
|
||||
class="input flex w-full items-center gap-2"
|
||||
class:input-bordered={bordered}
|
||||
class:input-xs={size === 'xs'}
|
||||
class:input-sm={size === 'sm'}
|
||||
class:input-lg={size === 'lg'}
|
||||
class:input-primary={color === 'primary'}
|
||||
class:input-secondary={color === 'secondary'}
|
||||
class:input-accent={color === 'accent'}
|
||||
class:input-ghost={color === 'ghost'}
|
||||
class:input-link={color === 'link'}
|
||||
class:input-info={color === 'info'}
|
||||
class:input-success={color === 'success'}
|
||||
class:input-warning={color === 'warning'}
|
||||
class:input-error={color === 'error' || error}
|
||||
>
|
||||
{#if typeof start === 'string'}
|
||||
{start}
|
||||
{:else}
|
||||
{@render start?.()}
|
||||
{/if}
|
||||
<input {disabled} {name} {placeholder} {type} class="grow" />
|
||||
{#if typeof end === 'string'}
|
||||
{end}
|
||||
{:else}
|
||||
{@render end?.()}
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
51
src/lib/components/DataInput/Textarea.stories.svelte
Normal file
51
src/lib/components/DataInput/Textarea.stories.svelte
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import Textarea from './Textarea.svelte';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Data Input/Textarea',
|
||||
component: Textarea,
|
||||
argTypes: {
|
||||
bordered: {
|
||||
control: 'boolean',
|
||||
},
|
||||
color: {
|
||||
control: 'select',
|
||||
options: [
|
||||
'default',
|
||||
'ghost',
|
||||
'primary',
|
||||
'secondary',
|
||||
'accent',
|
||||
'info',
|
||||
'success',
|
||||
'warning',
|
||||
'error',
|
||||
],
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
},
|
||||
error: {
|
||||
control: 'text',
|
||||
},
|
||||
label: {
|
||||
control: 'text',
|
||||
},
|
||||
placeholder: {
|
||||
control: 'text',
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['xs', 'sm', '-', 'lg'],
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet template(props: ComponentProps<typeof Textarea>)}
|
||||
<Textarea {...props} />
|
||||
{/snippet}
|
||||
|
||||
<Story name="Default" args={{ label: 'Label' }} children={template} />
|
||||
61
src/lib/components/DataInput/Textarea.svelte
Normal file
61
src/lib/components/DataInput/Textarea.svelte
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<script lang="ts">
|
||||
import type { DaisyColor, DaisySize } from '$lib/types';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
type Props = {
|
||||
bordered?: boolean;
|
||||
color?: Omit<DaisyColor, 'neutral'>;
|
||||
disabled?: boolean;
|
||||
error?: string | Snippet;
|
||||
form?: string;
|
||||
label?: string | Snippet;
|
||||
name?: string;
|
||||
placeholder?: string;
|
||||
size?: DaisySize;
|
||||
};
|
||||
let { bordered, color, error, label, size, ...props }: Props = $props();
|
||||
</script>
|
||||
|
||||
<label class="form-control w-full max-w-lg">
|
||||
<div class="label">
|
||||
<span
|
||||
class="label-text"
|
||||
class:text-primary={color === 'primary'}
|
||||
class:text-secondary={color === 'secondary'}
|
||||
class:text-accent={color === 'accent'}
|
||||
class:text-info={color === 'info'}
|
||||
class:text-success={color === 'success'}
|
||||
class:text-warning={color === 'warning'}
|
||||
class:text-error={color === 'error' || error}
|
||||
>
|
||||
{#if typeof label === 'string'}
|
||||
{label}
|
||||
{:else if label}
|
||||
{@render label()}
|
||||
{/if}
|
||||
</span>
|
||||
<span class="label-text-alt font-semibold text-error">
|
||||
{#if typeof error === 'string'}
|
||||
{error}
|
||||
{:else if error}
|
||||
{@render error()}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
class="textarea"
|
||||
class:textarea-bordered={bordered}
|
||||
class:textarea-xs={size === 'xs'}
|
||||
class:textarea-sm={size === 'sm'}
|
||||
class:textarea-lg={size === 'lg'}
|
||||
class:textarea-ghost={color === 'ghost'}
|
||||
class:textarea-primary={color === 'primary'}
|
||||
class:textarea-secondary={color === 'secondary'}
|
||||
class:textarea-accent={color === 'accent'}
|
||||
class:textarea-info={color === 'info'}
|
||||
class:textarea-success={color === 'success'}
|
||||
class:textarea-warning={color === 'warning'}
|
||||
class:textarea-error={color === 'error' || error}
|
||||
{...props}
|
||||
></textarea>
|
||||
</label>
|
||||
2
src/lib/components/DataInput/index.ts
Normal file
2
src/lib/components/DataInput/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as Textarea } from './Textarea.svelte';
|
||||
export { default as TextInput } from './TextInput.svelte';
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import Button from './Button.svelte';
|
||||
|
||||
export default Button;
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
<script lang="ts">
|
||||
import type { DaisyColor, DaisySize } from '$lib/types';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLInputTypeAttribute } from 'svelte/elements';
|
||||
import { fade as fadeTransition } from 'svelte/transition';
|
||||
|
||||
type Props = {
|
||||
bordered?: boolean;
|
||||
color?: Exclude<DaisyColor, 'neutral'>;
|
||||
disabled?: boolean;
|
||||
fade?: boolean;
|
||||
start?: Snippet | string;
|
||||
end?: Snippet | string;
|
||||
name: string;
|
||||
placeholder?: string;
|
||||
size?: DaisySize;
|
||||
type?: Extract<
|
||||
HTMLInputTypeAttribute,
|
||||
'email' | 'password' | 'search' | 'tel' | 'text' | 'url'
|
||||
>;
|
||||
};
|
||||
|
||||
let {
|
||||
bordered = false,
|
||||
color,
|
||||
disabled,
|
||||
fade,
|
||||
start,
|
||||
end,
|
||||
name,
|
||||
placeholder,
|
||||
size,
|
||||
type = 'text',
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<label
|
||||
transition:fadeTransition={{ duration: fade ? 200 : 0 }}
|
||||
class="input flex w-full items-center gap-2"
|
||||
class:input-bordered={bordered}
|
||||
class:input-xs={size === 'xs'}
|
||||
class:input-sm={size === 'sm'}
|
||||
class:input-lg={size === 'lg'}
|
||||
class:input-primary={color === 'primary'}
|
||||
class:input-secondary={color === 'secondary'}
|
||||
class:input-accent={color === 'accent'}
|
||||
class:input-ghost={color === 'ghost'}
|
||||
class:input-link={color === 'link'}
|
||||
class:input-info={color === 'info'}
|
||||
class:input-success={color === 'success'}
|
||||
class:input-warning={color === 'warning'}
|
||||
class:input-error={color === 'error'}
|
||||
>
|
||||
{#if typeof start === 'string'}
|
||||
{start}
|
||||
{:else}
|
||||
{@render start?.()}
|
||||
{/if}
|
||||
<input {disabled} {name} {placeholder} {type} class="grow" />
|
||||
{#if typeof end === 'string'}
|
||||
{end}
|
||||
{:else}
|
||||
{@render end?.()}
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
import TextInput from './TextInput.svelte';
|
||||
|
||||
export default TextInput;
|
||||
1
src/lib/regex/index.ts
Normal file
1
src/lib/regex/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './phone';
|
||||
1
src/lib/regex/phone.ts
Normal file
1
src/lib/regex/phone.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const PhoneRegex = /^\+?\d?(\d{3}\d{3}\d{4}|\d{3}-\d{3}-\d{4})$/;
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import { logger } from '$lib/server/logger';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface Configuration {
|
||||
app_version: string;
|
||||
}
|
||||
|
||||
export const LoadConfig = (): Configuration => {
|
||||
const { success, data, error } = z
|
||||
.object({
|
||||
VITE_APP_VERSION: z.string().default('development'),
|
||||
})
|
||||
.safeParse(import.meta.env);
|
||||
|
||||
if (!success) {
|
||||
logger.error(error.message);
|
||||
}
|
||||
|
||||
return {
|
||||
app_version: data!.VITE_APP_VERSION,
|
||||
};
|
||||
};
|
||||
|
||||
export const Config = LoadConfig();
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { version } from '$app/environment';
|
||||
import { builder } from '../builder';
|
||||
|
||||
builder.queryType({});
|
||||
|
|
@ -5,7 +6,7 @@ builder.queryType({});
|
|||
builder.queryField('version', (t) =>
|
||||
t.string({
|
||||
description: 'Application version',
|
||||
resolve: (parent, args, context) => context.config.app_version,
|
||||
resolve: () => version,
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
|||
4
src/lib/server/twilio/client.ts
Normal file
4
src/lib/server/twilio/client.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN } from '$env/static/private';
|
||||
import twilio from 'twilio';
|
||||
|
||||
export const TwilioClient = twilio(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN);
|
||||
1
src/lib/server/twilio/index.ts
Normal file
1
src/lib/server/twilio/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './client';
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { Config } from '$lib/server/config';
|
||||
import type { YogaInitialContext } from 'graphql-yoga';
|
||||
|
||||
export const Context = (initialContext: YogaInitialContext) => ({
|
||||
...initialContext,
|
||||
config: Config,
|
||||
config: {},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue